diff --git a/apps/server/src/modules/acquisition-organize/routes/rule-groups.ts b/apps/server/src/modules/acquisition-organize/routes/rule-groups.ts index b6ae044f..5b1e1bfe 100644 --- a/apps/server/src/modules/acquisition-organize/routes/rule-groups.ts +++ b/apps/server/src/modules/acquisition-organize/routes/rule-groups.ts @@ -330,7 +330,8 @@ export function registerRuleGroupRoutes(app: ModuleApp, d: AoDeps) { app, Api.rules.list, async (_c, { query }) => { - let q = db().selectFrom("rule_group").selectAll(); + // 订阅私有组(订阅表单的包含 / 排除)不在规则页列出,按订阅查看 + let q = db().selectFrom("rule_group").selectAll().where(sql`not (scope ? 'subscriptionId')`); if (query.includeDeleted !== "true") q = q.where("deleted_at", "is", null); if (query.kind) q = q.where("kind", "=", query.kind); if (query.source) q = q.where("source", "=", query.source); diff --git a/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts b/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts index f94b8fb3..d4d3e71f 100644 --- a/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts +++ b/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts @@ -1,11 +1,13 @@ import { type AcquisitionSlot, Api, + type SlotTemplate, type Subscription, type SubscriptionGroup, type SubscriptionHistory, type SubscriptionOverviewItem, type SubscriptionOverviewPage, + type SubscriptionParams, type SubscriptionSort, } from "@umt/contracts"; import type { SubscriptionRow } from "@umt/db"; @@ -27,6 +29,8 @@ import { subscriptionCoverage, } from "../services/coverage.ts"; import { loadRuleCatalog } from "../services/rules.ts"; +import { CALENDAR_MAX_DAYS, subscriptionCalendar } from "../services/subscription-calendar.ts"; +import { resetSubscription } from "../services/subscription-reset.ts"; import { applyEpisodeTotal, enqueueSubscriptionSearch, @@ -35,9 +39,11 @@ import { TRACKING_SUB_STATUSES, } from "../services/subscriptions.ts"; import { + applySlotPreset, applySlotUpgrade, type NormalizedParams, normalizeSubscriptionParams, + syncPrivateRuleGroup, } from "../services/subscriptions-params.ts"; import { actorOf, conflict, errMessage, jsonb, notFound, toApi, toApiList } from "../util.ts"; import { mount, paginate } from "./helpers.ts"; @@ -100,8 +106,22 @@ async function assertNoDuplicate( if (hit) conflict(`该作品同一季已有订阅:${hit.id}`); } -/** 保存后应用表单的派生写入:MP total_episode → 季总集数;best_version → 版本目标升级策略 */ -async function applyDerived(exec: Exec, sub: SubscriptionRow, n: NormalizedParams, now: Date) { +/** 没有收录策略模板、表单给了质量预设时,新订阅按这个模板建一个版本目标(预设随后写入) */ +const DEFAULT_SLOT_TEMPLATE: SlotTemplate = { name: "默认版本" }; + +/** + * 保存后应用表单的派生写入:包含 / 排除 → 订阅私有规则组;质量预设(MP resolution / quality / effect)→ + * 该订阅自己的版本目标;MP total_episode → 季总集数;best_version → 版本目标升级策略。 + */ +async function applyDerived( + exec: Exec, + sub: SubscriptionRow, + n: NormalizedParams, + now: Date, + prev: SubscriptionParams = {}, +) { + await syncPrivateRuleGroup(exec, sub, now); + await applySlotPreset(exec, { subscriptionId: sub.id, prev, next: n.params }); if (!sub.media_entity_id) return; if (n.episodeTotal) { const season = sub.season_unit_id @@ -296,6 +316,20 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) { }, { tag: TAG }, ); + mount( + app, + Api.subscriptions.calendar, + async (_c, { query }) => { + const from = Date.parse(`${query.from}T00:00:00Z`); + const to = Date.parse(`${query.to}T00:00:00Z`); + if (Number.isNaN(from) || Number.isNaN(to)) throw new ApiException("bad_request", "from / to 不是有效日期"); + if (to < from) throw new ApiException("bad_request", "to 不能早于 from"); + if ((to - from) / 86_400_000 + 1 > CALENDAR_MAX_DAYS) + throw new ApiException("bad_request", `日历跨度最多 ${CALENDAR_MAX_DAYS} 天`); + return subscriptionCalendar(db(), query, d.ports.now()); + }, + { tag: TAG }, + ); mount(app, Api.subscriptions.get, async (_c, { params }) => toApi(await load(params.id)), { tag: TAG, }); @@ -352,14 +386,20 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) { const pol = (body.policyRevisionId ? pols.find((p) => p.row.id === body.policyRevisionId) : undefined) ?? pols.find((p) => p.rules.defaultSlots?.length); - if (pol?.rules.defaultSlots?.length) { + // 策略没有模板但表单给了质量预设(MP resolution / quality / effect):建一个"默认版本"承载预设 + const preset = !!(norm.params.resolution || norm.params.source || norm.params.effect); + const templates = pol?.rules.defaultSlots?.length + ? pol.rules.defaultSlots + : preset + ? [DEFAULT_SLOT_TEMPLATE] + : []; + if (templates.length) await createSlotsFromTemplates(trx, { entityId: r.media_entity_id, subscriptionId: r.id, - policyRevisionId: pol.row.id, - templates: pol.rules.defaultSlots, + policyRevisionId: pol?.rules.defaultSlots?.length ? pol.row.id : null, + templates, }); - } } } await applyDerived(trx, r, norm, now); @@ -417,7 +457,8 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) { .where("id", "=", params.id) .returningAll() .executeTakeFirstOrThrow(); - if (norm) await applyDerived(trx, r, norm, d.ports.now()); + if (norm) await applyDerived(trx, r, norm, d.ports.now(), subParams(cur)); + else if (body.status !== undefined) await syncPrivateRuleGroup(trx, r, d.ports.now()); return r; }); invalidateCoverage(r.id); @@ -448,6 +489,8 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) { .where("id", "=", sub.id) .returningAll() .executeTakeFirstOrThrow(); + // 私有规则组随订阅删除(取消),重新订阅时按 params 重建 + if (to === "cancelled" || sub.status === "cancelled") await syncPrivateRuleGroup(db(), r, now); invalidateCoverage(sub.id); return r; }; @@ -538,6 +581,24 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) { { tag: TAG, status: 200 }, ); + mount( + app, + Api.subscriptions.reset, + async (c, { params }) => { + const sub = await load(params.id); + if (sub.status === "completed" || sub.status === "cancelled") + conflict(`订阅状态为 ${sub.status},用「重新订阅」`); + const r = await resetSubscription(d, sub); + await audit(db(), c, "reset", sub.id, { + releasedPlans: r.releasedPlans, + keptPlans: r.keptPlans, + clearedDeferrals: r.clearedDeferrals, + }); + return r; + }, + { tag: TAG, status: 200 }, + ); + // ── 搜索 / 进度 / 覆盖矩阵 ── mount(app, Api.subscriptions.search, async (_c, { params }) => search(await load(params.id)), { tag: TAG }); mount( diff --git a/apps/server/src/modules/acquisition-organize/services/acquisition.ts b/apps/server/src/modules/acquisition-organize/services/acquisition.ts index 55ba8800..29135b35 100644 --- a/apps/server/src/modules/acquisition-organize/services/acquisition.ts +++ b/apps/server/src/modules/acquisition-organize/services/acquisition.ts @@ -31,9 +31,19 @@ import { } from "@umt/domain"; import { fromParsedColumns, slotSpecFromRow, toContractSlotEvaluation } from "@umt/mapping"; import { hashJson, jsonb, startOfDay, toApi } from "../util.ts"; -import { type AoDeps, automationBlock, type Exec, enqueueTracked, loadInstances, recordCandidate } from "./common.ts"; +import { + type AoDeps, + automationBlock, + downloaderPathOf, + type Exec, + enqueueTracked, + hostPathOf, + type InstanceTopology, + loadInstances, + recordCandidate, +} from "./common.ts"; import { autoFillCheck, type ForceFill } from "./evaluate-autofill.ts"; -import { entityOriginalLanguage, loadRuleCatalog, type RuleCatalog, resolveRuleChain } from "./rules.ts"; +import { entityOriginalLanguage, loadRuleCatalog, mergeHard, type RuleCatalog, resolveRuleChain } from "./rules.ts"; import { clearDeferral, rangeOf, @@ -45,7 +55,6 @@ import { subParams, TRACKING_SUB_STATUSES, } from "./subscriptions.ts"; -import { withSubscriptionFilters } from "./subscriptions-params.ts"; import { decideVolume, loadVolumes } from "./volumes.ts"; // evaluateAcquisition:release.resolved → 匹配订阅/Slot → 模式 → 确定性评估 → 预算/缓存卷 → 冻结计划并预留。 @@ -545,7 +554,7 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string | await note(c, { ...base, decision: "skipped", mode: null, rejected: [], reasons: ["site_out_of_scope"] }); return done; } - const spec = withSubscriptionFilters(slotSpecFromRow(toApi(slot)), params); + const spec = slotSpecFromRow(toApi(slot)); const ctx = contextOf(rel, now); // 规则组:版本目标 ruleGroupIds → 订阅 ruleGroupIds → 订阅默认链;开升级叠加洗版默认链 c.rules ??= loadRuleCatalog(db); @@ -559,6 +568,8 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string | siteId: rel.site_id, subscriptionId: sub?.id, }); + // 规则组的结构化硬条件(订阅私有组的包含 / 排除)并入版本目标硬条件 + spec.hard = mergeHard(spec.hard, chain.hard); if (chain.groups.length) { spec.rules = { groups: chain.groups, atoms: chain.atoms }; c.langs ??= new Map(); @@ -882,10 +893,28 @@ async function gates( const { d } = c; const blocked = await automationBlock(d); if (blocked) return { ok: false, reasons: [blocked], rejected: [] }; - const wanted = (sub?.params as { downloaderInstanceId?: string } | null)?.downloaderInstanceId; + const sp = (sub?.params ?? {}) as { downloaderInstanceId?: string; downloadSavePath?: string }; + const wanted = sp.downloaderInstanceId; const insts = (await loadInstances(d.ctx.db)).filter((t) => t.savePath && (!wanted || t.inst.id === wanted)); const inst = insts[0]; if (!inst?.savePath) return { ok: false, reasons: ["no_downloader_instance"], rejected: [] }; + // 订阅指定的保存路径:本机路径经下载器路径映射换成下载器侧路径;本来就是下载器侧路径的原样用 + let savePath = inst.savePath; + if (sp.downloadSavePath) { + const mapped = subscriptionSavePath(inst, sp.downloadSavePath); + if (!mapped) + return { + ok: false, + reasons: ["download_save_path_unmapped"], + rejected: [ + { + code: "download_save_path_unmapped", + message: `保存路径 ${sp.downloadSavePath} 不在下载器 ${inst.inst.name} 的路径映射里`, + }, + ], + }; + savePath = mapped; + } const budget = await d.ctx.settings.get("budget"); const b = checkBudget( @@ -925,7 +954,19 @@ async function gates( ], }; } - return { ok: true, instanceId: inst.inst.id, volumeKey: inst.volumeKey, savePath: inst.savePath }; + return { ok: true, instanceId: inst.inst.id, volumeKey: inst.volumeKey, savePath }; +} + +/** + * 订阅保存路径 → 下载器侧路径(qB savepath):先按本机路径经映射换算;不行再看它是否已是下载器侧路径 + * (能反向映射回本机);实例没配映射视为同机同路径。都不成立返回 null(不猜)。 + */ +export function subscriptionSavePath(t: Pick, path: string): string | null { + const p = path.replace(/\/+$/, "") || "/"; + if (t.mappings.length === 0) return p; + const asHost = downloaderPathOf(t, p); + if (asHost) return asHost; + return hostPathOf(t, p) ? p : null; } async function reserve( diff --git a/apps/server/src/modules/acquisition-organize/services/downloads.ts b/apps/server/src/modules/acquisition-organize/services/downloads.ts index b0d1c42e..fe8f6c3f 100644 --- a/apps/server/src/modules/acquisition-organize/services/downloads.ts +++ b/apps/server/src/modules/acquisition-organize/services/downloads.ts @@ -220,7 +220,7 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?: if (plan.status !== "reserved") return { status: "skipped", reason: `plan_${plan.status}` }; const blocked = await automationBlock(d); if (blocked) return { status: "deferred", reason: blocked }; - const ev = (plan.evaluation ?? {}) as { instanceId?: string; volumeKey?: string }; + const ev = (plan.evaluation ?? {}) as { instanceId?: string; volumeKey?: string; savePath?: string }; const topo = (await loadInstances(db)).find((t) => t.inst.id === ev.instanceId); if (!topo?.savePath) return failPlan(d, plan.id, plan.slot_id, "downloader_unavailable"); @@ -239,7 +239,8 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?: const site = await db.selectFrom("site").selectAll().where("id", "=", rel.site_id).executeTakeFirst(); const client = await d.ports.downloader(topo.inst); const tag = topo.inst.managed_tag; - const savePath = topo.savePath; + // 评估时定下的保存路径(订阅 downloadSavePath 经路径映射后的下载器侧路径),没有用实例默认 + const savePath = ev.savePath || topo.savePath; const view = (t: QbTorrent) => ({ hash: t.hash, diff --git a/apps/server/src/modules/acquisition-organize/services/organize.ts b/apps/server/src/modules/acquisition-organize/services/organize.ts index b00c2db3..0f8483df 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize.ts @@ -45,7 +45,7 @@ import { effectiveNaming, ensureNamingVersion, loadNamingEntity, namingByVersion import { PathGuardError } from "./organize-guard.ts"; import { type FrozenTransfer, finalizeTransfer, planTransfer, type TransferSpec } from "./organize-transfer.ts"; import { hnrSatisfied } from "./retention.ts"; -import { slotComplete as sharedSlotComplete } from "./subscriptions.ts"; +import { slotComplete as sharedSlotComplete, subscriptionLibraryRoot } from "./subscriptions.ts"; // 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→ // 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。 @@ -247,11 +247,15 @@ async function chooseRoot( entity: MediaEntityRow, bindings: DomainBinding[], wanted?: string, + /** 订阅指定的库根:只在作品还没有路径绑定时用(首次整理建绑定),不存在/停用时按类型默认 */ + preferred?: string | null, ): Promise { const roots = await exec.selectFrom("library_root").selectAll().where("enabled", "=", true).execute(); if (wanted) return roots.find((r) => r.id === wanted) ?? null; const bound = bindings.find((b) => b.active !== false); if (bound) return roots.find((r) => r.id === bound.libraryRootId) ?? null; + const pref = preferred ? roots.find((r) => r.id === preferred) : undefined; + if (pref) return pref; const kinds = libraryKinds(entity.kind); return ( roots @@ -426,7 +430,13 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise toDomainPathBinding(toApi(r))); - const root = await chooseRoot(db, entity, bindings, req.targetLibraryRootId); + const root = await chooseRoot( + db, + entity, + bindings, + req.targetLibraryRootId, + await subscriptionLibraryRoot(db, { slotId, entityId: entity.id }), + ); if (!root) return fail("no_library_root", {}, entity.id); const targetDir = req.targetDir?.replace(/\/+$/, ""); if (targetDir !== undefined) { diff --git a/apps/server/src/modules/acquisition-organize/services/rules.ts b/apps/server/src/modules/acquisition-organize/services/rules.ts index 270eff2e..3ca7497e 100644 --- a/apps/server/src/modules/acquisition-organize/services/rules.ts +++ b/apps/server/src/modules/acquisition-organize/services/rules.ts @@ -25,6 +25,7 @@ import { type RuleGroupSpec, type RuleLayer, type RuleParseError, + type SlotHardConditions, type SlotRuleChain, serializeExpr, serializeLayers, @@ -144,7 +145,10 @@ export interface ResolveInput extends RuleTarget { * 引用链:订阅私有组 → (版本目标 ruleGroupIds || 订阅 ruleGroupIds || 该类默认链)→ 开升级时叠加洗版默认链。 * 按 scope 过滤、跳过停用/已删;pri 取链上最后一个带层的组(对齐 MP 多组串联)。 */ -export function resolveRuleChain(cat: RuleCatalog, input: ResolveInput): SlotRuleChain & { ids: string[] } { +export function resolveRuleChain( + cat: RuleCatalog, + input: ResolveInput, +): SlotRuleChain & { ids: string[]; hard: SlotHardConditions } { const explicit = input.slotRuleGroupIds?.length ? input.slotRuleGroupIds : (input.subRuleGroupIds ?? []); const ids: string[] = []; const push = (id: string) => { @@ -158,7 +162,41 @@ export function resolveRuleChain(cat: RuleCatalog, input: ResolveInput): SlotRul const rows = ids .map((id) => cat.groups.get(id)) .filter((g): g is RuleGroupRow => !!g && g.enabled && inScope(g, input)); - return { ids: rows.map((r) => r.id), groups: rows.map(groupSpecOf), atoms: cat.atoms }; + return { ids: rows.map((r) => r.id), groups: rows.map(groupSpecOf), atoms: cat.atoms, hard: chainHard(rows) }; +} + +/** + * 几组"至少命中一条"的正则取 AND:只有一组时原样返回;多组时合成一条前瞻正则 + * `^(?=[\s\S]*(?:a1|a2))(?=[\s\S]*(?:b1|b2))`(evaluate 的关键词匹配整体仍是"至少命中一条")。 + */ +export function allOfAny(lists: readonly (readonly string[])[]): string[] { + const xs = lists.filter((l) => l.length > 0); + if (xs.length === 0) return []; + if (xs.length === 1) return [...xs[0]!]; + return [`^${xs.map((l) => `(?=[\\s\\S]*(?:${l.join("|")}))`).join("")}`]; +} + +/** 规则组的结构化硬条件(rule_group.hard:include / exclude 正则)按链合并:各组 include 都要满足,exclude 取并集 */ +export function chainHard(rows: readonly Pick[]): SlotHardConditions { + const include: string[][] = []; + const exclude: string[] = []; + const strs = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []); + for (const r of rows) { + const h = (r.hard ?? {}) as { include?: unknown; exclude?: unknown }; + include.push(strs(h.include)); + exclude.push(...strs(h.exclude)); + } + const inc = allOfAny(include); + return { ...(inc.length ? { include: inc } : {}), ...(exclude.length ? { exclude } : {}) }; +} + +/** 版本目标硬条件 + 规则组硬条件:两边的 include 都要满足,exclude 取并集 */ +export function mergeHard(base: SlotHardConditions, extra: SlotHardConditions): SlotHardConditions { + if (!extra.include?.length && !extra.exclude?.length) return base; + const out: SlotHardConditions = { ...base }; + if (extra.exclude?.length) out.exclude = [...(base.exclude ?? []), ...extra.exclude]; + if (extra.include?.length) out.include = allOfAny([base.include ?? [], extra.include]); + return out; } /** 语义指纹:类型 + 范围 + 层 + 硬排除(规范化规则串)+ 是否必须命中 */ diff --git a/apps/server/src/modules/acquisition-organize/services/subscription-calendar.ts b/apps/server/src/modules/acquisition-organize/services/subscription-calendar.ts new file mode 100644 index 00000000..d2c372f7 --- /dev/null +++ b/apps/server/src/modules/acquisition-organize/services/subscription-calendar.ts @@ -0,0 +1,171 @@ +import type { + CalendarEntryState, + SubscriptionCalendar, + SubscriptionCalendarEntry, + SubscriptionMediaKind, +} from "@umt/contracts"; +import type { SubscriptionRow } from "@umt/db"; +import type { Exec } from "./common.ts"; +import { buildSubscriptionModels, type SubscriptionModel } from "./coverage.ts"; +import { TRACKING_SUB_STATUSES } from "./subscriptions.ts"; + +// 订阅日历(docs/design/subscriptions.md §4):事件 = 追踪中订阅范围内集的播出日期(集内容单元 air_date, +// 由 refreshMetadata 按 TMDB 季集数据写入),同日同季聚合;电影取上映日期(元数据 releaseDate)。 +// 状态取主版本那一行的格(没有版本目标时取"库内任一版本"):全已入库 have / 部分 partial / 都没有 missing / +// 播出日期在今天之后 upcoming。范围内没有播出日期的集列进 unknown(界面显示"播出日期未知")。 +// 与覆盖矩阵同一套模型(buildSubscriptionModels),口径一致。 + +const MAX_SUBS = 2000; +const MAX_UNKNOWN_EPISODES = 50; +export const CALENDAR_MAX_DAYS = 62; + +const pad = (n: number) => String(n).padStart(2, "0"); + +/** S01E03 / S01E03–E05 / S01E03、E05(不连续时逐个列) */ +export function calendarLabel(season: number | null, episodes: readonly number[]): string { + if (!episodes.length) return season === null ? "当期" : `S${pad(season)}`; + const s = season === null ? "" : `S${pad(season)}`; + const eps = [...episodes].sort((a, b) => a - b); + const contiguous = eps.every((e, i) => i === 0 || e === eps[i - 1]! + 1); + if (eps.length === 1) return `${s}E${pad(eps[0]!)}`; + if (contiguous) return `${s}E${pad(eps[0]!)}–E${pad(eps.at(-1)!)}`; + return `${s}${eps.map((e) => `E${pad(e)}`).join("、")}`; +} + +/** 媒体实体元数据里的上映日期(releaseDate / firstAirDate,字符串或 {value}) */ +export function releaseDateOf(metadata: unknown): string | null { + const m = (metadata ?? {}) as Record; + for (const k of ["releaseDate", "release_date"]) { + const v = m[k]; + const s = + typeof v === "string" + ? v + : typeof (v as { value?: unknown } | null)?.value === "string" + ? (v as { value: string }).value + : undefined; + if (s && /^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10); + } + return null; +} + +function stateOf(cells: { state: string; reason: string | null }[]): CalendarEntryState { + const have = cells.filter((c) => c.state === "have").length; + if (have === cells.length) return "have"; + if (have > 0) return "partial"; + if (cells.every((c) => c.state === "skipped" && c.reason === "not_aired")) return "upcoming"; + return "missing"; +} + +const titleOf = (m: SubscriptionModel) => m.entity?.title ?? m.sub.title_query ?? "未命名"; + +export function calendarFromModels( + models: readonly SubscriptionModel[], + range: { from: string; to: string }, + movieDates: ReadonlyMap, + today: string, +): SubscriptionCalendar { + const entries: SubscriptionCalendarEntry[] = []; + const unknown: SubscriptionCalendar["unknown"] = []; + const inRange = (d: string) => d >= range.from && d <= range.to; + for (const m of models) { + const row = m.rows.find((r) => r.primary) ?? m.rows.find((r) => r.slotId === null); + const base = { + subscriptionId: m.sub.id, + mediaEntityId: m.sub.media_entity_id, + title: titleOf(m), + mediaKind: m.sub.media_kind as SubscriptionMediaKind, + }; + if (m.sub.media_kind === "movie") { + const date = m.entity ? (movieDates.get(m.entity.id) ?? null) : null; + if (!date) { + unknown.push({ ...base, seasonNumber: null, episodes: [] }); + continue; + } + if (!inRange(date)) continue; + const have = row ? row.cells.some((c) => c.state === "have") : false; + entries.push({ + ...base, + date, + seasonNumber: null, + episodes: [], + label: "上映", + state: have ? "have" : date > today ? "upcoming" : "missing", + have: have ? 1 : 0, + total: 1, + }); + continue; + } + if (!row) { + unknown.push({ ...base, seasonNumber: m.seasonNumber, episodes: [] }); + continue; + } + // 展平列与格(顺序一致) + const cols = m.seasons.flatMap((s) => s.columns.map((c) => ({ ...c, seasonNumber: s.seasonNumber }))); + const groups = new Map(); + const undated = new Map(); + cols.forEach((c, i) => { + const cell = row.cells[i]; + if (!cell || (cell.state === "skipped" && cell.reason === "out_of_range")) return; + if (!c.airDate) { + if (cell.state !== "have" && c.episode !== null) { + const list = undated.get(c.seasonNumber) ?? []; + list.push(c.episode); + undated.set(c.seasonNumber, list); + } + return; + } + if (!inRange(c.airDate)) return; + const k = `${c.airDate}|${c.seasonNumber ?? ""}`; + const g = groups.get(k) ?? { date: c.airDate, season: c.seasonNumber, eps: [], cells: [] }; + if (c.episode !== null) g.eps.push(c.episode); + g.cells.push(cell); + groups.set(k, g); + }); + for (const g of groups.values()) { + const have = g.cells.filter((c) => c.state === "have").length; + entries.push({ + ...base, + date: g.date, + seasonNumber: g.season, + episodes: g.eps.sort((a, b) => a - b), + label: calendarLabel(g.season, g.eps), + state: stateOf(g.cells), + have, + total: g.cells.length, + }); + } + // 没有任何季列(总集数未知且没见过集)也算未知 + if (!cols.length) unknown.push({ ...base, seasonNumber: m.seasonNumber, episodes: [] }); + for (const [season, eps] of undated) + unknown.push({ ...base, seasonNumber: season, episodes: eps.slice(0, MAX_UNKNOWN_EPISODES) }); + } + entries.sort((a, b) => a.date.localeCompare(b.date) || a.title.localeCompare(b.title, "zh")); + return { from: range.from, to: range.to, entries, unknown }; +} + +/** 日历:追踪中(active / pending)订阅,可按类型筛 */ +export async function subscriptionCalendar( + exec: Exec, + input: { from: string; to: string; mediaKind?: SubscriptionMediaKind | undefined }, + now: Date, +): Promise { + let q = exec + .selectFrom("subscription") + .selectAll() + .where("status", "in", [...TRACKING_SUB_STATUSES]); + if (input.mediaKind) q = q.where("media_kind", "=", input.mediaKind); + const subs: SubscriptionRow[] = await q.orderBy("created_at", "desc").limit(MAX_SUBS).execute(); + const models = await buildSubscriptionModels(exec, subs, now); + const movieIds = subs.filter((s) => s.media_kind === "movie" && s.media_entity_id).map((s) => s.media_entity_id!); + const movieDates = new Map(); + if (movieIds.length) { + const rows = await exec.selectFrom("media_entity").select(["id", "metadata"]).where("id", "in", movieIds).execute(); + for (const r of rows) movieDates.set(r.id, releaseDateOf(r.metadata)); + } + return calendarFromModels( + subs.map((s) => models.get(s.id)!).filter(Boolean), + { from: input.from, to: input.to }, + movieDates, + now.toISOString().slice(0, 10), + ); +} diff --git a/apps/server/src/modules/acquisition-organize/services/subscription-reset.ts b/apps/server/src/modules/acquisition-organize/services/subscription-reset.ts new file mode 100644 index 00000000..f16b3f9c --- /dev/null +++ b/apps/server/src/modules/acquisition-organize/services/subscription-reset.ts @@ -0,0 +1,83 @@ +import type { SubscriptionResetResult } from "@umt/contracts"; +import type { SubscriptionRow } from "@umt/db"; +import { sql } from "@umt/db"; +import type { AoDeps } from "./common.ts"; +import { invalidateCoverage } from "./coverage.ts"; +import { applySlotEvent } from "./downloads.ts"; +import { INFLIGHT_PLAN_STATUSES } from "./subscriptions.ts"; + +// 订阅重置(差距 #24,对照 MP GET /subscribe/reset:清 note / episode_priority,lack = total,state = R)。 +// 我们的"note"是在途计划:已预留未提交的、以及下载任务已不在下载器里的已提交计划都释放(计划 cancelled, +// Slot 走 release 迁移);下载器里仍在跑的计划保留(否则会重复下载)。清掉版本目标的延后记录,标记立即重评。 +// 已入库资产、分集账本、总集数(含锁定)不动。 + +/** 仍算"活着"的下载任务状态:任务在下载器里,重置不能释放它的计划 */ +const LIVE_DOWNLOAD = ["queued", "submitting", "downloading", "paused", "stalled", "completed", "importing"]; + +export async function resetSubscription(d: AoDeps, sub: SubscriptionRow): Promise { + const now = d.ports.now(); + const out = await d.ctx.db.transaction().execute(async (trx) => { + const slots = sub.media_entity_id + ? await trx + .selectFrom("acquisition_slot") + .select("id") + .where("media_entity_id", "=", sub.media_entity_id) + .where((eb) => eb.or([eb("subscription_id", "=", sub.id), eb("subscription_id", "is", null)])) + .forUpdate() + .execute() + : []; + const slotIds = slots.map((s) => s.id); + const released: string[] = []; + const kept: string[] = []; + let cleared = 0; + if (slotIds.length) { + const plans = await sql<{ id: string; slot_id: string; status: string; live: boolean }>` + select p.id, p.slot_id, p.status, + exists (select 1 from download_task t + where t.acquisition_plan_id = p.id and t.removed_at is null + and t.status in (${sql.join(LIVE_DOWNLOAD)})) as live + from acquisition_plan p + where p.slot_id in (${sql.join(slotIds)}) and p.status in (${sql.join([...INFLIGHT_PLAN_STATUSES])}) + order by p.created_at + for update of p`.execute(trx); + for (const p of plans.rows) { + if (p.status === "submitted" && p.live) { + kept.push(p.id); + continue; + } + await trx + .updateTable("acquisition_plan") + .set({ status: "cancelled", status_reason: "subscription_reset" }) + .where("id", "=", p.id) + .execute(); + await applySlotEvent(d, trx, p.slot_id, { type: "release" }, { reason: "subscription_reset", planId: p.id }); + released.push(p.id); + } + const del = await trx.deleteFrom("slot_deferral").where("slot_id", "in", slotIds).executeTakeFirst(); + cleared = Number(del.numDeletedRows ?? 0); + await trx.updateTable("acquisition_slot").set({ next_evaluate_at: now }).where("id", "in", slotIds).execute(); + } + return { released, kept, cleared }; + }); + invalidateCoverage(sub.id); + let reevaluated = false; + if (sub.media_entity_id && (sub.status === "active" || sub.status === "pending")) { + try { + await d.ctx.engine.enqueue( + "evaluateAcquisition", + { subscriptionId: sub.id }, + { idempotencyKey: `reset:${sub.id}:${now.getTime()}`, traceId: sub.id }, + ); + reevaluated = true; + } catch (err) { + d.ctx.logger.debug({ err: { message: (err as Error).message } }, "重置后重评入队失败"); + } + } + return { + subscriptionId: sub.id, + releasedPlans: out.released, + keptPlans: out.kept, + clearedDeferrals: out.cleared, + reevaluated, + }; +} diff --git a/apps/server/src/modules/acquisition-organize/services/subscriptions-params.ts b/apps/server/src/modules/acquisition-organize/services/subscriptions-params.ts index dbf34759..f140e116 100644 --- a/apps/server/src/modules/acquisition-organize/services/subscriptions-params.ts +++ b/apps/server/src/modules/acquisition-organize/services/subscriptions-params.ts @@ -1,8 +1,16 @@ -import { type SubscriptionMediaKind, SubscriptionParams } from "@umt/contracts"; -import { type SlotSpec, validateKeywordPattern } from "@umt/domain"; +import { + type SubscriptionEffect, + type SubscriptionMediaKind, + SubscriptionParams, + type SubscriptionResolution, + type SubscriptionSourcePreset, +} from "@umt/contracts"; +import { type SubscriptionRow, sql } from "@umt/db"; +import { validateKeywordPattern } from "@umt/domain"; import { ApiException } from "../../../http/errors.ts"; -import { jsonb } from "../util.ts"; +import { hashJson, jsonb } from "../util.ts"; import type { Exec } from "./common.ts"; +import { allOfAny } from "./rules.ts"; // W9-E 订阅编辑表单字段(docs/design/subscriptions.md §3):params 规范化与校验。 // 写入时兼容 MP 订阅字段与旧键,规范化后只存 SubscriptionParams 列出的键(其余未知键原样保留)。 @@ -13,6 +21,8 @@ import type { Exec } from "./common.ts"; // keyword / keywords(字符串)→ keywords[] filter_groups(组名或 id)→ ruleGroupIds // best_version / best_version_full → 订阅版本目标的升级策略(按集 / 整季;电影 = 单片) // autoFillVersions{enabled, budget{perDay, bytes}} / autoFill:boolean → autoFill + fillBudget{unitsPerDay, bytesPerDay} +// resolution / quality(resource_type) / effect(MP 标签或正则)→ resolution / source / effect 质量预设 +// 保存后:include / exclude(+ 预设里只能用正则表达的 UHD / Atmos)同步为订阅私有规则组;质量预设写到该订阅自己的版本目标。 const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -40,21 +50,6 @@ const strList = (v: unknown, key: string): string[] => return String(x).trim(); }); -/** 订阅级 include/exclude 并入版本目标硬条件(作用于全部版本目标) */ -export function withSubscriptionFilters(spec: SlotSpec, params: SubscriptionParams): SlotSpec { - const inc = params.include ?? []; - const exc = params.exclude ?? []; - if (!inc.length && !exc.length) return spec; - return { - ...spec, - hard: { - ...spec.hard, - ...(inc.length ? { include: [...(spec.hard.include ?? []), ...inc] } : {}), - ...(exc.length ? { exclude: [...(spec.hard.exclude ?? []), ...exc] } : {}), - }, - }; -} - async function idsByKey( exec: Exec, table: "site" | "downloader_instance" | "rule_group", @@ -93,6 +88,236 @@ async function idsByKey( }); } +// ── MP 质量预设(resolution / quality / effect)── + +const RES_ENUM: readonly SubscriptionResolution[] = ["4k", "1080p", "720p"]; +const SRC_ENUM: readonly SubscriptionSourcePreset[] = [ + "bluray_disc", + "remux", + "bluray", + "uhd", + "web", + "hdtv", + "h265", + "h264", +]; +const FX_ENUM: readonly SubscriptionEffect[] = ["dv", "hdr", "sdr", "atmos"]; +const blank = (v: unknown) => v === undefined || v === null || v === "" || v === "全部" || v === "all"; + +/** MP 分辨率:枚举值、界面标签(4K / 1080P)或正则值("4K|2160p|x2160") */ +export function parseMpResolution(v: string): SubscriptionResolution | null { + const t = v.trim().toLowerCase(); + if ((RES_ENUM as readonly string[]).includes(t)) return t as SubscriptionResolution; + if (/4k|2160|uhd/.test(t)) return "4k"; + if (/1080/.test(t)) return "1080p"; + if (/720/.test(t)) return "720p"; + return null; +} + +/** MP 资源质量(quality / resource_type):蓝光原盘 / Remux / BluRay / UHD / WEB-DL / HDTV / H265 / H264 */ +export function parseMpSource(v: string): SubscriptionSourcePreset | null { + // MP 的值是正则文本("Blu-?Ray"):去掉 ? 与反斜杠再按字面判断 + const t = v.trim().toLowerCase().replace(/[?\\]/g, ""); + if ((SRC_ENUM as readonly string[]).includes(t)) return t as SubscriptionSourcePreset; + if (/原盘|minibd|vc-?1|blu-?ray\.\+avc|bdmv/.test(t)) return "bluray_disc"; + if (/remux/.test(t)) return "remux"; + if (/blu-?ray|蓝光/.test(t)) return "bluray"; + if (/uhd|ultra-?hd/.test(t)) return "uhd"; + if (/web/.test(t)) return "web"; + if (/hdtv/.test(t)) return "hdtv"; + if (/265|hevc/.test(t)) return "h265"; + if (/264|avc/.test(t)) return "h264"; + return null; +} + +/** MP 特效:杜比视界 / 杜比全景声 / HDR / SDR(标签或正则值) */ +export function parseMpEffect(v: string): SubscriptionEffect | null { + const t = v.trim().toLowerCase(); + if ((FX_ENUM as readonly string[]).includes(t)) return t as SubscriptionEffect; + if (/vision|dovi|杜比视界|\bdv\b/.test(t)) return "dv"; + if (/atmos|全景声/.test(t)) return "atmos"; + if (/sdr/.test(t) && !/hdr/.test(t)) return "sdr"; + if (/hdr/.test(t)) return "hdr"; + return null; +} + +/** 质量预设 → 版本目标硬条件 / 软偏好;只能用正则表达的(UHD、Atmos)进私有规则组的包含 */ +export interface PresetConditions { + hard: Record; + soft: Record; + include: string[]; +} + +export function presetConditions(p: Pick): PresetConditions { + const hard: Record = {}; + const soft: Record = {}; + const include: string[] = []; + switch (p.resolution) { + case "4k": + hard.resolutions = ["2160p"]; + break; + case "1080p": + hard.resolutions = ["1080p", "1080i"]; + break; + case "720p": + hard.resolutions = ["720p"]; + break; + } + switch (p.source) { + case "bluray_disc": + hard.sources = ["BluRay-Disc"]; + break; + case "remux": + hard.sources = ["REMUX"]; + break; + case "bluray": + // MP "Blu-?Ray" 匹配整个蓝光族;软偏好按 原盘 > REMUX > 压制 + hard.sources = ["BluRay-Disc", "REMUX", "BluRay"]; + soft.sources = ["BluRay-Disc", "REMUX", "BluRay"]; + break; + case "uhd": + include.push("UHD|Ultra-?HD"); + break; + case "web": + hard.sources = ["WEB-DL", "WEBRip"]; + soft.sources = ["WEB-DL", "WEBRip"]; + break; + case "hdtv": + hard.sources = ["HDTV"]; + break; + case "h265": + hard.codecs = ["H.265"]; + break; + case "h264": + hard.codecs = ["H.264"]; + break; + } + switch (p.effect) { + case "dv": + hard.hdr = { anyOf: ["DV"] }; + soft.hdr = ["DV", "HDR10+", "HDR10"]; + break; + case "hdr": + hard.hdr = { anyOf: ["HDR10", "HDR10+", "HLG", "HDR Vivid"] }; + soft.hdr = ["HDR10+", "HDR10", "HDR Vivid", "HLG"]; + break; + case "sdr": + hard.hdr = { requireSdr: true }; + break; + case "atmos": + include.push("Atmos"); + break; + } + return { hard, soft, include }; +} + +const sameJson = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); + +/** + * 质量预设写到该订阅自己的版本目标:新预设给出的字段覆盖;旧预设给过、新预设不再给的字段, + * 只有当版本目标上的值仍等于旧预设生成的值时才删掉(手改过的保留)。有变化 revision+1。 + */ +export async function applySlotPreset( + exec: Exec, + input: { subscriptionId: string; prev: SubscriptionParams; next: SubscriptionParams }, +): Promise { + const a = presetConditions(input.prev); + const b = presetConditions(input.next); + if (sameJson(a, b)) return 0; + const slots = await exec + .selectFrom("acquisition_slot") + .select(["id", "hard_conditions", "soft_preferences", "revision"]) + .where("subscription_id", "=", input.subscriptionId) + .execute(); + let n = 0; + const merge = (cur: Record, from: Record, to: Record) => { + const out = { ...cur }; + for (const [k, v] of Object.entries(from)) if (!(k in to) && sameJson(out[k], v)) delete out[k]; + return { ...out, ...to }; + }; + for (const s of slots) { + const curHard = (s.hard_conditions ?? {}) as Record; + const curSoft = (s.soft_preferences ?? {}) as Record; + const hard = merge(curHard, a.hard, b.hard); + const soft = merge(curSoft, a.soft, b.soft); + if (sameJson(hard, curHard) && sameJson(soft, curSoft)) continue; + await exec + .updateTable("acquisition_slot") + .set({ hard_conditions: jsonb(hard), soft_preferences: jsonb(soft), revision: s.revision + 1 }) + .where("id", "=", s.id) + .execute(); + n++; + } + return n; +} + +// ── 订阅私有规则组(include / exclude)── + +/** 私有组名:同名校验按未删除的组,订阅 id 前 13 位足够区分 */ +export const privateGroupName = (subId: string) => `订阅私有 ${subId.slice(0, 13)}`; + +/** 订阅当前的私有规则组(未删除) */ +export async function privateRuleGroup(exec: Exec, subscriptionId: string) { + return exec + .selectFrom("rule_group") + .selectAll() + .where("deleted_at", "is", null) + .where(sql`scope ->> 'subscriptionId'`, "=", subscriptionId) + .orderBy("created_at") + .executeTakeFirst(); +} + +/** + * 订阅表单的包含 / 排除(+ 预设 UHD / Atmos)→ 订阅私有规则组 hard(scope.subscriptionId,不在规则页显示)。 + * 条件为空或订阅已取消 → 软删;有变化 revision+1。返回组 id(没有为 null)。 + */ +export async function syncPrivateRuleGroup( + exec: Exec, + sub: Pick, + now: Date, +): Promise { + const p = (sub.params ?? {}) as SubscriptionParams; + // 表单包含(至少命中一条)与预设 UHD / Atmos(每条都要命中)取 AND + const include = allOfAny([p.include ?? [], ...presetConditions(p).include.map((x) => [x])]); + const exclude = [...(p.exclude ?? [])]; + const hard = { + ...(include.length ? { include } : {}), + ...(exclude.length ? { exclude } : {}), + }; + const cur = await privateRuleGroup(exec, sub.id); + const empty = !include.length && !exclude.length; + if (empty || sub.status === "cancelled") { + if (cur) + await exec.updateTable("rule_group").set({ deleted_at: now, updated_at: now }).where("id", "=", cur.id).execute(); + return null; + } + const fingerprint = `private:${sub.id}:${hashJson(hard).slice(0, 16)}`; + if (cur) { + if (sameJson(cur.hard, hard) && cur.enabled) return cur.id; + await exec + .updateTable("rule_group") + .set({ hard: jsonb(hard), fingerprint, enabled: true, revision: cur.revision + 1, updated_at: now }) + .where("id", "=", cur.id) + .execute(); + return cur.id; + } + const row = await exec + .insertInto("rule_group") + .values({ + name: privateGroupName(sub.id), + description: "订阅表单的包含 / 排除,随订阅删除", + kind: "acquisition", + scope: jsonb({ subscriptionId: sub.id }), + ladder_required: false, + source: "manual", + fingerprint, + hard: jsonb(hard), + }) + .returning("id") + .executeTakeFirstOrThrow(); + return row.id; +} + /** * 规范化 + 校验订阅 params(POST / PATCH 共用)。未知键原样保留;兼容字段改写后删除原键。 */ @@ -167,6 +392,27 @@ export async function normalizeSubscriptionParams( ? "full_pack" : "per_unit"; } + // MP 质量预设:resolution / quality(resource_type)/ effect;"全部"或空 = 不设 + const presets: [string[], "resolution" | "source" | "effect", (v: string) => string | null, string][] = [ + [["resolution"], "resolution", parseMpResolution, "分辨率"], + [["quality", "resource_type", "source"], "source", parseMpSource, "资源质量"], + [["effect"], "effect", parseMpEffect, "特效"], + ]; + for (const [keys, to, parse, label] of presets) { + let raw: unknown; + for (const k of keys) { + const v = k === to ? src[k] : take(k); + if (raw === undefined && v !== undefined) raw = v; + } + if (blank(raw)) { + delete src[to]; + continue; + } + if (typeof raw !== "string") bad(`${label}只能是字符串`); + const v = parse(raw as string); + if (!v) bad(`${label}无法识别:${String(raw)}`); + src[to] = v; + } const legacyFill = take("autoFillVersions") as | { enabled?: unknown; budget?: { perDay?: unknown; bytes?: unknown } } | undefined; diff --git a/apps/server/src/modules/acquisition-organize/services/subscriptions.ts b/apps/server/src/modules/acquisition-organize/services/subscriptions.ts index 695e0f0a..3b1cc089 100644 --- a/apps/server/src/modules/acquisition-organize/services/subscriptions.ts +++ b/apps/server/src/modules/acquisition-organize/services/subscriptions.ts @@ -708,6 +708,38 @@ export async function sweepMetadataRefresh(d: AoDeps): Promise { return n; } +/** + * 订阅指定的库根(params.libraryRootId):下载计划所属版本目标的订阅优先,否则取该作品最近的未取消订阅。 + * 整理时只在作品没有路径绑定时使用(首次建绑定)。 + */ +export async function subscriptionLibraryRoot( + exec: Exec, + input: { slotId: string | null; entityId: string }, +): Promise { + const pick = (p: unknown) => { + const v = (p as SubscriptionParams | null)?.libraryRootId; + return typeof v === "string" && v ? v : null; + }; + if (input.slotId) { + const r = await exec + .selectFrom("acquisition_slot as s") + .innerJoin("subscription as sub", "sub.id", "s.subscription_id") + .select("sub.params") + .where("s.id", "=", input.slotId) + .executeTakeFirst(); + if (r && pick(r.params)) return pick(r.params); + } + const subs = await exec + .selectFrom("subscription") + .select("params") + .where("media_entity_id", "=", input.entityId) + .where("status", "!=", "cancelled") + .orderBy("created_at", "desc") + .execute(); + for (const s of subs) if (pick(s.params)) return pick(s.params); + return null; +} + // ── 订阅进度(API / UI) ── export async function subscriptionProgress(exec: Exec, subId: string): Promise { diff --git a/apps/server/src/modules/sources-identity/service/metadata-totals.ts b/apps/server/src/modules/sources-identity/service/metadata-totals.ts index 28a368f3..bd73446d 100644 --- a/apps/server/src/modules/sources-identity/service/metadata-totals.ts +++ b/apps/server/src/modules/sources-identity/service/metadata-totals.ts @@ -1,11 +1,12 @@ -import type { CandidateSeason } from "@umt/adapters"; +import type { CandidateSeason, TmdbSeasonDetail } from "@umt/adapters"; import { sql } from "@umt/db"; import { resolveEpisodeTotal } from "@umt/domain"; import type { Exec } from "./common.ts"; // W5-C 季总集数账本(content_unit(kind=season).episode_total):refreshMetadata 用 TMDB seasons[].episodeCount 刷新。 // 规则在 domain resolveEpisodeTotal:只升;TMDB 回退不低于已覆盖最高集;人工锁定不改;未公布(0)保持未知。 -// 季单元不存在时按元数据建(confirmed);不预建集单元(集单元仍由发布/文件懒建,provisional 不代表总数)。 +// 季单元不存在时按元数据建(confirmed)。集单元:追踪中订阅范围内的季按 TMDB 季详情建或补播出日期(confirmed, +// 订阅日历与覆盖矩阵的未播出判定用);其余集单元仍由发布/文件懒建。 export interface SeasonTotalsResult { seasons: { season: number; total: number | null; known: boolean; changed: boolean }[]; @@ -80,3 +81,89 @@ export async function applySeasonTotals( out.activated = Number(act.numUpdatedRows ?? 0); return out; } + +/** + * 追踪中订阅需要播出日期的季:订阅季(接续后续季时含之后各季);订阅没指定季时取最新两季。最多 6 季。 + * 没有追踪中的订阅返回空(不额外请求 TMDB)。 + */ +export async function trackedSeasonsForAirDates( + exec: Exec, + entityId: string, + known: readonly number[], +): Promise { + const subs = await exec + .selectFrom("subscription as s") + .leftJoin("content_unit as u", "u.id", "s.season_unit_id") + .select(["s.season_scope", "u.season_number"]) + .where("s.media_entity_id", "=", entityId) + .where("s.media_kind", "<>", "movie") + .where("s.status", "in", ["active", "pending", "paused"]) + .execute(); + if (!subs.length) return []; + const all = [...new Set(known.filter((n) => n >= 1))].sort((a, b) => a - b); + const want = new Set(); + for (const s of subs) { + if (s.season_number === null || s.season_number === undefined) { + for (const n of all.slice(-2)) want.add(n); + continue; + } + want.add(s.season_number); + if (s.season_scope === "following_seasons") for (const n of all) if (n > s.season_number) want.add(n); + } + return [...want] + .sort((a, b) => b - a) + .slice(0, 6) + .sort((a, b) => a - b); +} + +/** + * TMDB 季详情 → 集单元播出日期:按(作品, 季, 集)找未退役的集单元,没有就建(confirmed,挂季单元); + * 播出日期只在 TMDB 给了值且不同时改写;标题只补空。返回改动的集数。 + */ +export async function applyEpisodeAirDates(exec: Exec, entityId: string, detail: TmdbSeasonDetail): Promise { + const season = detail.seasonNumber; + if (season < 1 || !detail.episodes.length) return 0; + const seasonUnit = await ensureSeason(exec, entityId, season, detail.name); + const units = await exec + .selectFrom("content_unit") + .select(["id", "episode_number", sql`to_char(air_date, 'YYYY-MM-DD')`.as("air"), "title"]) + .where("media_entity_id", "=", entityId) + .where("kind", "=", "episode") + .where("season_number", "=", season) + .where("status", "!=", "retired") + .orderBy("created_at") + .execute(); + let n = 0; + for (const e of detail.episodes.slice(0, 500)) { + if (e.episodeNumber < 1) continue; + const air = e.airDate && /^\d{4}-\d{2}-\d{2}/.test(e.airDate) ? e.airDate.slice(0, 10) : null; + const hit = units.find((u) => u.episode_number === e.episodeNumber); + if (hit) { + const patch = { + ...(air && air !== hit.air ? { air_date: air } : {}), + ...(!hit.title && e.name ? { title: e.name } : {}), + }; + if (Object.keys(patch).length) { + await exec.updateTable("content_unit").set(patch).where("id", "=", hit.id).execute(); + n++; + } + continue; + } + await exec + .insertInto("content_unit") + .values({ + media_entity_id: entityId, + parent_unit_id: seasonUnit, + kind: "episode", + season_number: season, + episode_number: e.episodeNumber, + status: "confirmed", + display_order: e.episodeNumber, + title: e.name, + air_date: air, + }) + .execute(); + n++; + } + return n; +} diff --git a/apps/server/src/modules/sources-identity/service/metadata.ts b/apps/server/src/modules/sources-identity/service/metadata.ts index 42e64c3e..5335d7b0 100644 --- a/apps/server/src/modules/sources-identity/service/metadata.ts +++ b/apps/server/src/modules/sources-identity/service/metadata.ts @@ -1,4 +1,4 @@ -import { type MetadataCandidate, TmdbError } from "@umt/adapters"; +import { type MetadataCandidate, TmdbError, type TmdbSeasonDetail } from "@umt/adapters"; import type { EntityMetadataField, FieldOverrideInput, FinalFieldValue, MetadataSource } from "@umt/contracts"; import { sql } from "@umt/db"; import { ApiException } from "../../../http/errors.ts"; @@ -6,7 +6,12 @@ import { jsonb } from "../rows.ts"; import type { SourcesRuntime } from "../runtime.ts"; import { type Actor, audit, type Exec } from "./common.ts"; import { resolveEntityId, seedAliases } from "./entities.ts"; -import { applySeasonTotals, type SeasonTotalsResult } from "./metadata-totals.ts"; +import { + applyEpisodeAirDates, + applySeasonTotals, + type SeasonTotalsResult, + trackedSeasonsForAirDates, +} from "./metadata-totals.ts"; // refreshMetadata:字段级刷新(REQ §7.3)。来源值写 metadata_field;最终值 = 覆盖(锁)> 已锁字段保持 > 来源值。 // 只更新 media_entity 的展示字段与别名,不触碰 path_binding(路径绑定不随元数据变化)。 @@ -96,6 +101,22 @@ export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string) } throw e; } + // 追踪中订阅范围内的季:拉季详情写集播出日期(订阅日历);失败不影响字段刷新 + const airSeasons: TmdbSeasonDetail[] = []; + if (ident.entity_type === "tv" && detail.seasons?.length && typeof tmdb.season === "function") { + const want = await trackedSeasonsForAirDates( + ctx.db, + id, + detail.seasons.map((x) => x.seasonNumber), + ); + for (const n of want) { + try { + airSeasons.push(await tmdb.season(ident.external_id, n)); + } catch (e) { + ctx.logger.debug({ entityId: id, season: n, err: { message: (e as Error).message } }, "季详情获取失败"); + } + } + } const now = rt.now(); return ctx.db.transaction().execute(async (trx) => { await writeTmdbFields(trx, id, detail, `tmdb:${ident.entity_type}/${ident.external_id}`, now); @@ -113,6 +134,7 @@ export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string) ident.entity_type === "tv" && detail.seasons?.length ? await applySeasonTotals(trx, id, detail.seasons, now) : null; + for (const sd of airSeasons) await applyEpisodeAirDates(trx, id, sd); await trx.updateTable("media_entity").set({ last_metadata_refresh_at: now }).where("id", "=", id).execute(); return { status: "refreshed" as const, ...r, ...(totals ? { seasons: totals.seasons } : {}) }; }); diff --git a/apps/server/src/modules/sources-identity/tasks.ts b/apps/server/src/modules/sources-identity/tasks.ts index 4589fcb8..08112ff6 100644 --- a/apps/server/src/modules/sources-identity/tasks.ts +++ b/apps/server/src/modules/sources-identity/tasks.ts @@ -158,7 +158,15 @@ export async function searchSubscription( keywords.push(v); if (keywords.length >= SEARCH_MAX_KEYWORDS) break; } - const params = (sub.params ?? {}) as { siteIds?: string[]; searchIntervalHours?: number }; + const params = (sub.params ?? {}) as { siteIds?: string[]; searchIntervalHours?: number; keywords?: string[] }; + // 订阅的搜索关键词(MP keyword)追加在标题/别名之后,不占 SEARCH_MAX_KEYWORDS;结果同样经识别 → 评估 + // (订阅私有规则组的包含 / 排除在评估时过滤,与 RSS 来的资源同一口径) + for (const k of params.keywords ?? []) { + const v = typeof k === "string" ? k.trim() : ""; + if (!v || seen.has(v.toLowerCase())) continue; + seen.add(v.toLowerCase()); + keywords.push(v); + } let sq = db.selectFrom("site").selectAll().where("enabled", "=", true).where("health_status", "!=", "failing"); if (params.siteIds?.length) sq = sq.where("id", "in", params.siteIds); const sites = await sq.orderBy("name").execute(); diff --git a/apps/server/test/e2e/e7-tv-series.test.ts b/apps/server/test/e2e/e7-tv-series.test.ts index 13caea9c..dd6e6581 100644 --- a/apps/server/test/e2e/e7-tv-series.test.ts +++ b/apps/server/test/e2e/e7-tv-series.test.ts @@ -292,6 +292,8 @@ describe.skipIf(!hasPg)("E7 常规剧集纵切", () => { .select(["episode_number", "status"]) .where("media_entity_id", "=", entityId) .where("kind", "=", "episode") + // 追踪订阅的最新季(S2/S3)由元数据刷新按 TMDB 季详情建集单元(日历用);这里只看包所在的 S1 + .where("season_number", "=", 1) .orderBy("episode_number") .execute(); expect(eps).toEqual([ diff --git a/apps/server/test/subscriptions/subscriptions-c1a-sources.test.ts b/apps/server/test/subscriptions/subscriptions-c1a-sources.test.ts new file mode 100644 index 00000000..97e447e6 --- /dev/null +++ b/apps/server/test/subscriptions/subscriptions-c1a-sources.test.ts @@ -0,0 +1,154 @@ +import { createMockNexusPhpSite, createMockTmdb, type MockNexusPhpSite, type MockTmdb } from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts"; +import { trackedSeasonsForAirDates } from "../../src/modules/sources-identity/service/metadata-totals.ts"; +import { searchSubscription } from "../../src/modules/sources-identity/tasks.ts"; +import { createHarness, type Harness, json, pgAvailable } from "../sources-identity/harness.ts"; + +// C1-A:订阅搜索关键词追加到主动搜索;元数据刷新按 TMDB 季详情给追踪中订阅的季写集播出日期(订阅日历数据源)。 + +const up = await pgAvailable(); + +describe.skipIf(!up)("订阅搜索关键词与集播出日期", () => { + let h: Harness; + let site: MockNexusPhpSite; + let tmdb: MockTmdb; + let siteId: string; + + beforeAll(async () => { + site = await createMockNexusPhpSite(); + tmdb = await createMockTmdb(); + h = await createHarness("subsc1a", { tmdbUrl: tmdb.url }); + const ok = await json( + await h.req("/sites", { + body: { + key: "audiences", + name: "观众", + adapter: "nexusphp", + baseUrl: `${site.baseUrl}/`, + rateLimit: { minIntervalMs: 0, concurrency: 4 }, + }, + }), + ); + expect(ok.status).toBe(200); + siteId = ok.body.data.id; + await h.req("/site-accounts", { body: { siteId, label: "主号", authKind: "cookie", credential: site.cookie } }); + }); + afterAll(async () => { + await h?.close(); + await site?.close(); + await tmdb?.close(); + }); + + it("params.keywords 追加在标题之后(去重,不占标题检索词上限),每个站点每个词搜一次", async () => { + const e = ( + await h.db + .insertInto("media_entity") + .values({ kind: "movie", title: "Dune", original_title: "Dune", year: 2021, status: "active" }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + const sub = ( + await h.db + .insertInto("subscription") + .values({ + media_entity_id: e, + media_kind: "movie", + mode: "once", + params: JSON.stringify({ keywords: ["Dune Part One", "dune", " "] }), + }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + const r = await searchSubscription(h.rt, sub); + expect(r).toMatchObject({ status: "ok", keywords: ["Dune", "Dune Part One"], sites: [siteId], searches: 2 }); + const snaps = await h.db + .selectFrom("site_snapshot") + .select("query") + .where("kind", "=", "live_search") + .where("query", "=", "Dune Part One") + .execute(); + expect(snaps).toHaveLength(1); + }); + + it("元数据刷新:追踪中订阅的季按 TMDB 季详情建集单元并写播出日期;已有集单元只补日期;没有追踪订阅不拉季详情", async () => { + const e = ( + await h.db + .insertInto("media_entity") + .values({ kind: "series", title: "The Bear", original_title: "The Bear", year: 2022, status: "active" }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + await h.db + .insertInto("external_identity") + .values({ + media_entity_id: e, + provider: "tmdb", + entity_type: "tv", + external_id: "136315", + status: "accepted", + }) + .execute(); + expect(await trackedSeasonsForAirDates(h.db, e, [1, 2, 3])).toEqual([]); + const seasonRequests = () => tmdb.callsTo(/\/season\/\d+/).length; + const before = seasonRequests(); + await refreshMetadata(h.rt, e); + expect(seasonRequests()).toBe(before); + + const s2 = ( + await h.db + .insertInto("content_unit") + .values({ media_entity_id: e, kind: "season", season_number: 2, status: "confirmed" }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + // 已有的 E01(发布懒建,没有日期) + const e1 = ( + await h.db + .insertInto("content_unit") + .values({ media_entity_id: e, kind: "episode", season_number: 2, episode_number: 1, status: "provisional" }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + await h.db + .insertInto("subscription") + .values({ + media_entity_id: e, + media_kind: "series", + mode: "continuous", + season_unit_id: s2, + season_scope: "following_seasons", + }) + .execute(); + expect(await trackedSeasonsForAirDates(h.db, e, [1, 2, 3])).toEqual([2, 3]); + const r = await refreshMetadata(h.rt, e); + expect(r.status).toBe("refreshed"); + const eps = await h.db + .selectFrom("content_unit") + .select(["id", "season_number", "episode_number", "status", "title"]) + .select((eb) => eb.fn("to_char", [eb.ref("air_date"), eb.val("YYYY-MM-DD")]).as("air")) + .where("media_entity_id", "=", e) + .where("kind", "=", "episode") + .orderBy("season_number") + .orderBy("episode_number") + .execute(); + expect(eps.filter((x) => x.season_number === 2)).toHaveLength(10); + expect(eps.filter((x) => x.season_number === 3)).toHaveLength(10); + expect(eps.filter((x) => x.season_number === 1)).toHaveLength(0); + const first = eps.find((x) => x.season_number === 2 && x.episode_number === 1)!; + expect(first).toMatchObject({ id: e1, status: "provisional", air: "2023-06-22" }); + expect(eps.find((x) => x.season_number === 2 && x.episode_number === 2)).toMatchObject({ + status: "confirmed", + air: "2023-06-29", + }); + // 再刷新幂等:不重复建 + await refreshMetadata(h.rt, e); + const n = await h.db + .selectFrom("content_unit") + .select((eb) => eb.fn.countAll().as("n")) + .where("media_entity_id", "=", e) + .where("kind", "=", "episode") + .executeTakeFirstOrThrow(); + expect(Number(n.n)).toBe(20); + }); +}); diff --git a/apps/server/test/subscriptions/subscriptions-c1a.test.ts b/apps/server/test/subscriptions/subscriptions-c1a.test.ts new file mode 100644 index 00000000..e2ee728f --- /dev/null +++ b/apps/server/test/subscriptions/subscriptions-c1a.test.ts @@ -0,0 +1,513 @@ +import { type DB, sql } from "@umt/db"; +import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } from "@umt/testkit"; +import type { Kysely } from "kysely"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + createHarness, + fakeDisk, + type Harness, + pgAvailable, + seedBase, + seedEntity, + seedRelease, +} from "../../src/modules/acquisition-organize/__tests__/harness.ts"; +import { + evaluateAcquisition, + subscriptionSavePath, +} from "../../src/modules/acquisition-organize/services/acquisition.ts"; +import { submitDownload } from "../../src/modules/acquisition-organize/services/downloads.ts"; +import { planImport } from "../../src/modules/acquisition-organize/services/organize.ts"; +import { allOfAny, chainHard, mergeHard } from "../../src/modules/acquisition-organize/services/rules.ts"; +import { calendarLabel, releaseDateOf } from "../../src/modules/acquisition-organize/services/subscription-calendar.ts"; +import { + parseMpEffect, + parseMpResolution, + parseMpSource, + presetConditions, +} from "../../src/modules/acquisition-organize/services/subscriptions-params.ts"; + +// C1-A 订阅遗留收尾:包含 / 排除 → 订阅私有规则组(评估链纳入、随订阅删除)、MP 分辨率 / 来源 / 特效 → 版本目标、 +// 保存路径经路径映射传给 qB、库根用于首次整理、下载器实例、重置进度、日历。 + +const hasPg = await pgAvailable(); +const SIZE = 300_000; + +describe("C1-A 纯函数", () => { + it("MP 质量字段:标签 / 正则值 / 枚举都能识别", () => { + expect(parseMpResolution("4K|2160p|x2160")).toBe("4k"); + expect(parseMpResolution("1080[pi]|x1080")).toBe("1080p"); + expect(parseMpResolution("720P")).toBe("720p"); + expect(parseMpResolution("480p")).toBeNull(); + expect(parseMpSource("Blu-?Ray.+VC-?1|Blu-?Ray.+AVC|UHD.+blu-?ray.+HEVC|MiniBD")).toBe("bluray_disc"); + expect(parseMpSource("Remux")).toBe("remux"); + expect(parseMpSource("Blu-?Ray")).toBe("bluray"); + expect(parseMpSource("UHD|UltraHD")).toBe("uhd"); + expect(parseMpSource("WEB-?DL|WEB-?RIP")).toBe("web"); + expect(parseMpSource("[Hx].?265|HEVC")).toBe("h265"); + expect(parseMpEffect("Dolby[\\s.]+Vision|DOVI|[\\s.]+DV[\\s.]+")).toBe("dv"); + expect(parseMpEffect("Dolby[\\s.]*\\+?Atmos|Atmos")).toBe("atmos"); + expect(parseMpEffect("[\\s.]+HDR[\\s.]+|HDR10|HDR10\\+")).toBe("hdr"); + expect(parseMpEffect("SDR")).toBe("sdr"); + }); + + it("质量预设 → 硬条件 / 软偏好;UHD / Atmos 只能走包含", () => { + expect(presetConditions({ resolution: "4k", source: "web", effect: "dv" })).toEqual({ + hard: { resolutions: ["2160p"], sources: ["WEB-DL", "WEBRip"], hdr: { anyOf: ["DV"] } }, + soft: { sources: ["WEB-DL", "WEBRip"], hdr: ["DV", "HDR10+", "HDR10"] }, + include: [], + }); + expect(presetConditions({ source: "uhd", effect: "atmos" }).include).toEqual(["UHD|Ultra-?HD", "Atmos"]); + expect(presetConditions({ effect: "sdr" }).hard).toEqual({ hdr: { requireSdr: true } }); + }); + + it("包含取 AND:多组合成前瞻正则;排除取并集", () => { + expect(allOfAny([["a", "b"]])).toEqual(["a", "b"]); + const [re] = allOfAny([["中字", "CHS"], ["UHD"]]); + const rx = new RegExp(re!, "i"); + expect(rx.test("Show 2160p UHD 中字")).toBe(true); + expect(rx.test("Show 2160p 中字")).toBe(false); + expect(chainHard([{ hard: { include: ["x"] } }, { hard: { exclude: ["y"] } }, { hard: {} }])).toEqual({ + include: ["x"], + exclude: ["y"], + }); + expect(mergeHard({ exclude: ["a"] }, { exclude: ["b"] })).toEqual({ exclude: ["a", "b"] }); + expect(mergeHard({ include: ["a"] }, {})).toEqual({ include: ["a"] }); + }); + + it("保存路径:本机路径经映射换成下载器路径;下载器侧路径原样;都不匹配为 null;无映射同路径", () => { + const t = { + inst: { id: "i1" } as never, + mappings: [{ instanceId: "i1", downloaderPath: "/downloads", hostPath: "/mnt/cache" }], + }; + expect(subscriptionSavePath(t, "/mnt/cache/tv/")).toBe("/downloads/tv"); + expect(subscriptionSavePath(t, "/downloads/tv")).toBe("/downloads/tv"); + expect(subscriptionSavePath(t, "/elsewhere")).toBeNull(); + expect(subscriptionSavePath({ inst: t.inst, mappings: [] }, "/x/y")).toBe("/x/y"); + }); + + it("日历标签与上映日期", () => { + expect(calendarLabel(1, [3])).toBe("S01E03"); + expect(calendarLabel(1, [4, 3])).toBe("S01E03–E04"); + expect(calendarLabel(2, [1, 3])).toBe("S02E01、E03"); + expect(calendarLabel(null, [])).toBe("当期"); + expect(releaseDateOf({ releaseDate: { value: "2026-10-02", source: "tmdb" } })).toBe("2026-10-02"); + expect(releaseDateOf({})).toBeNull(); + }); +}); + +describe.skipIf(!hasPg)("C1-A 订阅遗留收尾(真实 PG + mock qB)", () => { + let h: Harness; + let qb: MockQb; + let lib: TempTree; + let lib2: TempTree; + let cache: TempTree; + const disk = fakeDisk(10_000_000_000); + const torrents = new Map(); + let base: Awaited>; + const db = (): Kysely => h.db; + + beforeAll(async () => { + qb = await createMockQb({ addVisibleAfterPolls: 0 }); + lib = await createTempTree({}, { sentinel: true, prefix: "umt-c1a-lib-" }); + lib2 = await createTempTree({}, { sentinel: true, prefix: "umt-c1a-lib2-" }); + cache = await createTempTree({ "Root.Pick.2021.1080p.BluRay.x264-GRP.mkv": SIZE }, { prefix: "umt-c1a-cache-" }); + disk.state.watch = [cache.root]; + disk.setPct(10); + h = await createHarness("subsc1a", { + diskUsage: disk.fn, + torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }), + }); + base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url }); + }, 60_000); + afterAll(async () => { + await h?.close(); + await qb?.close(); + for (const t of [lib, lib2, cache]) await t?.cleanup(); + }); + + async function release(entityId: string, title: string) { + const t = makeTorrent({ name: `${title}.mkv`, length: SIZE, pieceSeed: Math.floor(Math.random() * 1e6) }); + const id = await seedRelease(db(), { siteId: base.siteId, entityId, title, size: SIZE, hash: t.hash }); + torrents.set(id, t.bytes); + return id; + } + const privateGroups = (subId: string) => + db() + .selectFrom("rule_group") + .selectAll() + .where(sql`scope ->> 'subscriptionId'`, "=", subId) + .orderBy("created_at") + .execute(); + const slotsOf = (subId: string) => + db().selectFrom("acquisition_slot").selectAll().where("subscription_id", "=", subId).orderBy("position").execute(); + + it("包含 / 排除 → 私有规则组:规则页不列出;修改 revision+1;清空软删;删除订阅软删,重新订阅重建", async () => { + const e = await seedEntity(db(), { kind: "series", title: "私有组剧", year: 2025 }); + const c = await h.api("POST", "/subscriptions", { + mediaEntityId: e, + mediaKind: "series", + params: { include: "中字|CHS", exclude: ["HDTV"] }, + }); + expect(c.status, JSON.stringify(c.body)).toBe(201); + const id = c.body.data.id as string; + let gs = await privateGroups(id); + expect(gs).toHaveLength(1); + expect(gs[0]).toMatchObject({ kind: "acquisition", deleted_at: null, revision: 1, ladder_required: false }); + expect(gs[0]!.hard).toEqual({ include: ["中字|CHS"], exclude: ["HDTV"] }); + const list = await h.api("GET", "/rules?limit=200"); + expect(list.body.data.items.some((g: { id: string }) => g.id === gs[0]!.id)).toBe(false); + + const p = await h.api("PATCH", `/subscriptions/${id}`, { + params: { include: ["中字"], exclude: ["HDTV", "CAM"] }, + }); + expect(p.status).toBe(200); + gs = await privateGroups(id); + expect(gs[0]).toMatchObject({ revision: 2, hard: { include: ["中字"], exclude: ["HDTV", "CAM"] } }); + + await h.api("PATCH", `/subscriptions/${id}`, { params: {} }); + expect((await privateGroups(id))[0]!.deleted_at).not.toBeNull(); + + await h.api("PATCH", `/subscriptions/${id}`, { params: { exclude: ["CAM"] } }); + const live = (await privateGroups(id)).filter((g) => !g.deleted_at); + expect(live).toHaveLength(1); + expect(live[0]!.hard).toEqual({ exclude: ["CAM"] }); + + const del = await h.api("POST", "/subscriptions/batch", { ids: [id], action: "delete" }); + expect(del.body.data.results[0].ok).toBe(true); + expect((await privateGroups(id)).filter((g) => !g.deleted_at)).toHaveLength(0); + const re = await h.api("POST", `/subscriptions/${id}/resubscribe`); + expect(re.status, JSON.stringify(re.body)).toBe(200); + const again = (await privateGroups(id)).filter((g) => !g.deleted_at); + expect(again).toHaveLength(1); + expect(again[0]!.hard).toEqual({ exclude: ["CAM"] }); + }); + + it("评估链纳入私有组:包含没命中 / 排除命中都拒绝;命中包含建计划;与版本目标自己的包含取 AND", async () => { + const e = await seedEntity(db(), { kind: "movie", title: "Filter Film", year: 2024 }); + const c = await h.api("POST", "/subscriptions", { + mediaEntityId: e, + mediaKind: "movie", + params: { include: ["CHS"], exclude: ["HDTV"], resolution: "1080P" }, + }); + expect(c.status, JSON.stringify(c.body)).toBe(201); + const [slot] = await slotsOf(c.body.data.id); + expect(slot).toBeTruthy(); + const noInc = await release(e, "Filter.Film.2024.1080p.WEB-DL.x264-GRP"); + const r1 = await evaluateAcquisition(h.d, { sourceReleaseId: noInc }); + expect(r1.plans).toHaveLength(0); + const cand = await db() + .selectFrom("artifact") + .select("content") + .where("title", "=", "candidate_evaluation") + .where(sql`content->>'sourceReleaseId' = ${noInc}`) + .executeTakeFirstOrThrow(); + expect(JSON.stringify(cand.content)).toContain("include_missed"); + const excl = await release(e, "Filter.Film.2024.1080p.HDTV.CHS.x264-GRP"); + expect((await evaluateAcquisition(h.d, { sourceReleaseId: excl })).plans).toHaveLength(0); + const ok = await release(e, "Filter.Film.2024.1080p.WEB-DL.CHS.x264-GRP"); + const r3 = await evaluateAcquisition(h.d, { sourceReleaseId: ok }); + expect(r3.plans, JSON.stringify(r3)).toHaveLength(1); + // 版本目标自己也要求 "GRP2":与私有组的 CHS 取 AND + await db().updateTable("acquisition_plan").set({ status: "cancelled" }).where("id", "=", r3.plans[0]!).execute(); + await db() + .updateTable("acquisition_slot") + .set({ + status: "empty", + hard_conditions: JSON.stringify({ resolutions: ["1080p", "1080i"], include: ["GRP2"] }), + }) + .where("id", "=", slot!.id) + .execute(); + const onlyGrp = await release(e, "Filter.Film.2024.1080p.WEB-DL.x264-GRP2"); + expect((await evaluateAcquisition(h.d, { sourceReleaseId: onlyGrp })).plans).toHaveLength(0); + const both = await release(e, "Filter.Film.2024.1080p.WEB-DL.CHS.x264-GRP2"); + expect((await evaluateAcquisition(h.d, { sourceReleaseId: both })).plans).toHaveLength(1); + }); + + it("MP 分辨率 / 来源 / 特效:新建时建默认版本目标承载预设;改预设覆盖同一字段,手改过的字段保留;无法识别 400", async () => { + const e = await seedEntity(db(), { kind: "series", title: "预设剧", year: 2025 }); + const bad = await h.api("POST", "/subscriptions", { + mediaEntityId: e, + mediaKind: "series", + params: { resolution: "480p" }, + }); + expect(bad.status).toBe(400); + const c = await h.api("POST", "/subscriptions", { + mediaEntityId: e, + mediaKind: "series", + params: { resolution: "4K|2160p|x2160", quality: "WEB-?DL|WEB-?RIP", effect: "杜比视界" }, + }); + expect(c.status, JSON.stringify(c.body)).toBe(201); + const id = c.body.data.id as string; + expect(c.body.data.params).toMatchObject({ resolution: "4k", source: "web", effect: "dv" }); + expect(c.body.data.params.quality).toBeUndefined(); + let [slot] = await slotsOf(id); + expect(slot).toMatchObject({ name: "默认版本" }); + expect(slot!.hard_conditions).toEqual({ + resolutions: ["2160p"], + sources: ["WEB-DL", "WEBRip"], + hdr: { anyOf: ["DV"] }, + }); + expect(slot!.soft_preferences).toEqual({ sources: ["WEB-DL", "WEBRip"], hdr: ["DV", "HDR10+", "HDR10"] }); + // 手改来源 + await db() + .updateTable("acquisition_slot") + .set({ hard_conditions: JSON.stringify({ ...(slot!.hard_conditions as object), sources: ["WEB-DL"] }) }) + .where("id", "=", slot!.id) + .execute(); + const p = await h.api("PATCH", `/subscriptions/${id}`, { params: { resolution: "1080p" } }); + expect(p.status).toBe(200); + [slot] = await slotsOf(id); + // 分辨率换成新预设;特效预设去掉 → 删;来源手改过 → 保留 + expect(slot!.hard_conditions).toEqual({ resolutions: ["1080p", "1080i"], sources: ["WEB-DL"] }); + expect(slot!.soft_preferences).toEqual({}); + // 预设 UHD / Atmos 进私有组包含,与表单包含取 AND + await h.api("PATCH", `/subscriptions/${id}`, { params: { source: "uhd", include: ["中字"] } }); + const g = (await privateGroups(id)).find((x) => !x.deleted_at)!; + expect((g.hard as { include: string[] }).include).toHaveLength(1); + expect(new RegExp((g.hard as { include: string[] }).include[0]!, "i").test("X UHD 中字")).toBe(true); + expect(new RegExp((g.hard as { include: string[] }).include[0]!, "i").test("X 中字")).toBe(false); + }); + + it("下载提交:订阅指定下载器与保存路径(本机路径经映射)→ 计划评估与 qB savepath;映射不到 → 不建计划", async () => { + const inst2 = await db() + .insertInto("downloader_instance") + .values({ name: "qb-c1a-2", base_url: qb.url, username: "admin", settings: JSON.stringify({ savePath: "/dl" }) }) + .returning("id") + .executeTakeFirstOrThrow(); + await db() + .insertInto("path_mapping") + .values({ + view_kind: "downloader", + downloader_instance_id: inst2.id, + remote_prefix: "/dl", + local_prefix: cache.root, + }) + .execute(); + const e = await seedEntity(db(), { kind: "movie", title: "Save Path Film", year: 2023 }); + const c = await h.api("POST", "/subscriptions", { + mediaEntityId: e, + mediaKind: "movie", + params: { downloaderInstanceId: inst2.id, downloadSavePath: `${cache.root}/movies`, resolution: "1080p" }, + }); + expect(c.status, JSON.stringify(c.body)).toBe(201); + const rel = await release(e, "Save.Path.Film.2023.1080p.WEB-DL.x264-GRP"); + const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel }); + expect(ev.plans, JSON.stringify(ev)).toHaveLength(1); + const plan = await db() + .selectFrom("acquisition_plan") + .selectAll() + .where("id", "=", ev.plans[0]!) + .executeTakeFirstOrThrow(); + expect(plan.evaluation).toMatchObject({ instanceId: inst2.id, savePath: "/dl/movies" }); + const s = await submitDownload(h.d, plan.id); + expect(s.status, JSON.stringify(s)).toBe("submitted"); + const dt = await db() + .selectFrom("download_task") + .selectAll() + .where("id", "=", s.downloadTaskId!) + .executeTakeFirstOrThrow(); + expect(dt.instance_id).toBe(inst2.id); + expect(dt.save_path).toBe("/dl/movies"); + + // 映射不到的保存路径:不建计划,理由可见 + const e2 = await seedEntity(db(), { kind: "movie", title: "Unmapped Film", year: 2023 }); + await h.api("POST", "/subscriptions", { + mediaEntityId: e2, + mediaKind: "movie", + params: { downloaderInstanceId: inst2.id, downloadSavePath: "/nowhere", resolution: "1080p" }, + }); + const rel2 = await release(e2, "Unmapped.Film.2023.1080p.WEB-DL.x264-GRP"); + const ev2 = await evaluateAcquisition(h.d, { sourceReleaseId: rel2 }); + expect(ev2.plans).toHaveLength(0); + expect(ev2.skipped).toContainEqual({ sourceReleaseId: rel2, reason: "download_save_path_unmapped" }); + }); + + it("库根:作品还没有路径绑定时,整理按订阅指定的库根;已有绑定仍按绑定", async () => { + const root2 = await db() + .insertInto("library_root") + .values({ name: "c1a-root2", media_kind: "series", local_path: lib2.root, management_mode: "managed" }) + .returning("id") + .executeTakeFirstOrThrow(); + const e = await seedEntity(db(), { kind: "movie", title: "Root Pick", year: 2021 }); + const dry = () => + planImport(h.d, { + sourcePaths: [cache.path("Root.Pick.2021.1080p.BluRay.x264-GRP.mkv")], + mediaEntityId: e, + action: "copy", + trigger: "manual", + actorType: "user", + dryRun: true, + }); + const before = await dry(); + expect(before.plan?.libraryRootId, JSON.stringify(before)).toBe(base.rootId); + const c = await h.api("POST", "/subscriptions", { + mediaEntityId: e, + mediaKind: "movie", + params: { libraryRootId: root2.id }, + }); + expect(c.status).toBe(201); + const after = await dry(); + expect(after.plan?.libraryRootId, JSON.stringify(after)).toBe(root2.id); + }); + + it("重置:取消未提交的计划与下载已不在的计划,保留仍在下载的;清延后;排立即重评;已完成 409", async () => { + const e = await seedEntity(db(), { kind: "series", title: "Reset Show", year: 2024 }); + const c = await h.api("POST", "/subscriptions", { + mediaEntityId: e, + mediaKind: "series", + params: { resolution: "1080p" }, + }); + const id = c.body.data.id as string; + const [slot] = await slotsOf(id); + const r1 = await release(e, "Reset.Show.S01E01.1080p.WEB-DL.x264-GRP"); + const r2 = await release(e, "Reset.Show.S01E02.1080p.WEB-DL.x264-GRP"); + const r3 = await release(e, "Reset.Show.S01E03.1080p.WEB-DL.x264-GRP"); + const [p1] = (await evaluateAcquisition(h.d, { sourceReleaseId: r1 })).plans; + const [p2] = (await evaluateAcquisition(h.d, { sourceReleaseId: r2 })).plans; + const [p3] = (await evaluateAcquisition(h.d, { sourceReleaseId: r3 })).plans; + expect([p1, p2, p3].every(Boolean)).toBe(true); + // p1 在下载;p2 已提交但下载任务被移除;p3 只预留 + expect((await submitDownload(h.d, p1!)).status).toBe("submitted"); + const s2 = await submitDownload(h.d, p2!); + await db() + .updateTable("download_task") + .set({ removed_at: new Date() }) + .where("id", "=", s2.downloadTaskId!) + .execute(); + const r2b = await release(e, "Reset.Show.S01E02.1080p.WEB-DL.x265-OTHER"); + await evaluateAcquisition(h.d, { sourceReleaseId: r2b }); + expect(await db().selectFrom("slot_deferral").select("id").where("slot_id", "=", slot!.id).execute()).toHaveLength( + 1, + ); + + h.engine.enqueued.length = 0; + const res = await h.api("POST", `/subscriptions/${id}/reset`); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.data).toMatchObject({ + subscriptionId: id, + keptPlans: [p1], + clearedDeferrals: 1, + reevaluated: true, + }); + expect([...res.body.data.releasedPlans].sort()).toEqual([p2, p3].sort()); + const statuses = await db() + .selectFrom("acquisition_plan") + .select(["id", "status", "status_reason"]) + .where("id", "in", [p1!, p2!, p3!]) + .execute(); + expect(statuses.find((x) => x.id === p1)?.status).toBe("submitted"); + expect(statuses.filter((x) => x.status === "cancelled").map((x) => x.status_reason)).toEqual([ + "subscription_reset", + "subscription_reset", + ]); + expect(await db().selectFrom("slot_deferral").select("id").where("slot_id", "=", slot!.id).execute()).toHaveLength( + 0, + ); + expect((await slotsOf(id))[0]!.next_evaluate_at).not.toBeNull(); + expect(h.engine.enqueued.some((q) => q.task === "evaluateAcquisition")).toBe(true); + const audit = await db() + .selectFrom("audit_event") + .select("action") + .where("target_id", "=", id) + .where("action", "=", "subscription.reset") + .execute(); + expect(audit).toHaveLength(1); + + await db().updateTable("subscription").set({ status: "completed" }).where("id", "=", id).execute(); + expect((await h.api("POST", `/subscriptions/${id}/reset`)).status).toBe(409); + }); + + it("日历:集播出日期同日同季聚合,状态取主版本;没有播出日期列 unknown;电影取上映日期;跨度超限 400", async () => { + const today = new Date().toISOString().slice(0, 10); + const addDays = (n: number) => new Date(Date.now() + n * 86_400_000).toISOString().slice(0, 10); + const e = await seedEntity(db(), { kind: "series", title: "日历剧", year: 2026 }); + const season = await db() + .insertInto("content_unit") + .values({ + media_entity_id: e, + kind: "season", + season_number: 1, + status: "confirmed", + episode_total: 4, + episode_total_source: "tmdb", + }) + .returning("id") + .executeTakeFirstOrThrow(); + const ep = async (n: number, air: string | null) => + ( + await db() + .insertInto("content_unit") + .values({ + media_entity_id: e, + parent_unit_id: season.id, + kind: "episode", + season_number: 1, + episode_number: n, + status: "confirmed", + air_date: air, + }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + const e1 = await ep(1, addDays(-3)); + await ep(2, addDays(-3)); + await ep(3, addDays(5)); + await ep(4, null); + const c = await h.api("POST", "/subscriptions", { + mediaEntityId: e, + mediaKind: "series", + seasonUnitId: season.id, + params: { resolution: "1080p" }, + }); + expect(c.status, JSON.stringify(c.body)).toBe(201); + const subId = c.body.data.id as string; + // E01 已入库(单版本目标:作品下资产都算) + const a = await db() + .insertInto("media_asset") + .values({ + media_entity_id: e, + kind: "video", + size_bytes: SIZE, + media_info: JSON.stringify({ _umt: { quality: { resolution: "1080p", source: "WEB-DL" } } }), + }) + .returning("id") + .executeTakeFirstOrThrow(); + await db().insertInto("asset_unit_coverage").values({ media_asset_id: a.id, content_unit_id: e1 }).execute(); + const m = await seedEntity(db(), { kind: "movie", title: "日历片", year: 2026 }); + await db() + .updateTable("media_entity") + .set({ metadata: JSON.stringify({ releaseDate: { value: addDays(10), source: "tmdb" } }) }) + .where("id", "=", m) + .execute(); + await h.api("POST", "/subscriptions", { mediaEntityId: m, mediaKind: "movie" }); + const m2 = await seedEntity(db(), { kind: "movie", title: "无日期片", year: 2026 }); + await h.api("POST", "/subscriptions", { mediaEntityId: m2, mediaKind: "movie" }); + + const cal = await h.api("GET", `/subscriptions/calendar?from=${addDays(-10)}&to=${addDays(20)}`); + expect(cal.status, JSON.stringify(cal.body)).toBe(200); + const mine = cal.body.data.entries.filter((x: { subscriptionId: string }) => x.subscriptionId === subId); + expect(mine).toEqual([ + expect.objectContaining({ + date: addDays(-3), + seasonNumber: 1, + episodes: [1, 2], + label: "S01E01–E02", + state: "partial", + have: 1, + total: 2, + }), + expect.objectContaining({ date: addDays(5), episodes: [3], state: "upcoming", have: 0, total: 1 }), + ]); + expect(cal.body.data.unknown).toContainEqual( + expect.objectContaining({ subscriptionId: subId, seasonNumber: 1, episodes: [4] }), + ); + expect(cal.body.data.entries).toContainEqual( + expect.objectContaining({ title: "日历片", date: addDays(10), label: "上映", state: "upcoming" }), + ); + expect(cal.body.data.unknown).toContainEqual(expect.objectContaining({ title: "无日期片", episodes: [] })); + const movies = await h.api("GET", `/subscriptions/calendar?from=${addDays(-10)}&to=${addDays(20)}&mediaKind=movie`); + expect(movies.body.data.entries.every((x: { mediaKind: string }) => x.mediaKind === "movie")).toBe(true); + expect((await h.api("GET", `/subscriptions/calendar?from=${today}&to=${addDays(90)}`)).status).toBe(400); + expect((await h.api("GET", `/subscriptions/calendar?from=${today}&to=${addDays(-1)}`)).status).toBe(400); + }); +}); diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index 23b351fe..dd01363a 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -629,6 +629,28 @@ export const Api = { }, R, ), + /** 日历(静态路径,须先于 /subscriptions/{id} 注册) */ + calendar: ep({ + method: "get", + path: "/subscriptions/calendar", + scope: "read", + summary: + "订阅日历:追踪中订阅范围内集的播出日期(同日同季聚合)与入库状态,电影取上映日期;没有播出日期的列在 unknown。from/to 为 YYYY-MM-DD,跨度不超过 62 天", + query: z.object({ + from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + mediaKind: policy.SubscriptionMediaKind.optional(), + }), + response: policy.SubscriptionCalendar, + }), + reset: ep({ + method: "post", + path: "/subscriptions/{id}/reset", + scope: "admin", + summary: "重置进度:取消已预留未提交、下载任务已不在的计划,清延后记录,立即重评;仍在下载的计划与已入库资产保留", + params: IdParam, + response: policy.SubscriptionResetResult, + }), /** 总览:在 crud 列表上加分组/排序/筛选,条目带卡片派生字段,另给各分组计数(docs/design/subscriptions.md §1) */ list: ep({ method: "get", diff --git a/packages/contracts/src/policy.ts b/packages/contracts/src/policy.ts index abe03987..714299e5 100644 --- a/packages/contracts/src/policy.ts +++ b/packages/contracts/src/policy.ts @@ -97,12 +97,32 @@ export const FillBudget = z .strict(); export type FillBudget = z.infer; +/** MP 分辨率(resolution)→ 版本目标硬条件 resolutions */ +export const SubscriptionResolution = z.enum(["4k", "1080p", "720p"]); +export type SubscriptionResolution = z.infer; +/** MP 资源质量(quality / resource_type)→ 版本目标硬条件 sources / codecs;uhd 写进订阅私有规则组的包含 */ +export const SubscriptionSourcePreset = z.enum([ + "bluray_disc", + "remux", + "bluray", + "uhd", + "web", + "hdtv", + "h265", + "h264", +]); +export type SubscriptionSourcePreset = z.infer; +/** MP 特效(effect)→ 版本目标硬条件 hdr + 软偏好 hdr 顺序;atmos 写进订阅私有规则组的包含 */ +export const SubscriptionEffect = z.enum(["dv", "hdr", "sdr", "atmos"]); +export type SubscriptionEffect = z.infer; + /** * subscription.params 的已知键(JSON 列,其余键原样保留): * siteIds 站点范围;episodeRange 集范围(对照 MP start/total_episode);searchIntervalHours 主动搜索周期(0 = 关); * stopAfter 截止时间;downloaderInstanceId 指定下载器;ruleGroupIds 规则组;keywords 追加检索词; * include/exclude 订阅级关键词正则(作用于全部版本目标);libraryRootId 库根;downloadSavePath 下载保存路径; - * autoFill/fillBudget 多版本自动补全。 + * autoFill/fillBudget 多版本自动补全;resolution/source/effect 质量预设(MP resolution/quality/effect)。 + * include/exclude 保存时同步为订阅私有规则组(scope.subscriptionId,随订阅删除),评估只读规则组。 * 写入时兼容 MP 字段与旧键(sites/downloader/save_path/start_episode/filter_groups/keyword/autoFillVersions…), * 服务端规范化后只存本表列出的键,见 docs/runbook/subscriptions.md。 */ @@ -130,6 +150,10 @@ export const SubscriptionParams = z downloadSavePath: z.string().min(1).max(1000).optional(), autoFill: AutoFillMode.optional(), fillBudget: FillBudget.optional(), + /** MP resolution / quality / effect:保存时写到该订阅自己的版本目标(硬条件 + 软偏好) */ + resolution: SubscriptionResolution.optional(), + source: SubscriptionSourcePreset.optional(), + effect: SubscriptionEffect.optional(), }) .passthrough(); export type SubscriptionParams = z.infer; @@ -424,6 +448,56 @@ export const SubscriptionBatchResult = z.object({ }); export type SubscriptionBatchResult = z.infer; +/** 重置进度:清在途标记(未提交/已失联的计划)与延后记录,重新评估;不删资产 */ +export const SubscriptionResetResult = z.object({ + subscriptionId: Id, + /** 被取消的计划(已预留未提交,或下载任务已不在) */ + releasedPlans: z.array(Id), + /** 下载器里仍在下载的计划:保留 */ + keptPlans: z.array(Id), + clearedDeferrals: z.number().int(), + reevaluated: z.boolean(), +}); +export type SubscriptionResetResult = z.infer; + +/** 日历条目状态:have 已入库 / partial 部分 / missing 未入库 / upcoming 未播出 */ +export const CalendarEntryState = z.enum(["have", "partial", "missing", "upcoming"]); +export type CalendarEntryState = z.infer; +export const SubscriptionCalendarEntry = z.object({ + /** YYYY-MM-DD */ + date: z.string(), + subscriptionId: Id, + mediaEntityId: Id.nullable(), + title: z.string(), + mediaKind: SubscriptionMediaKind, + /** 电影为 null */ + seasonNumber: z.number().int().nullable(), + episodes: z.array(z.number().int()), + /** 如 "S01E03–E04";电影 "上映" */ + label: z.string(), + state: CalendarEntryState, + have: z.number().int(), + total: z.number().int(), +}); +export type SubscriptionCalendarEntry = z.infer; +export const SubscriptionCalendar = z.object({ + from: z.string(), + to: z.string(), + entries: z.array(SubscriptionCalendarEntry), + /** 范围内季集没有播出日期(TMDB 未给或未刷新)的订阅:播出日期未知 */ + unknown: z.array( + z.object({ + subscriptionId: Id, + title: z.string(), + mediaKind: SubscriptionMediaKind, + seasonNumber: z.number().int().nullable(), + /** 没有播出日期的集;电影为空 */ + episodes: z.array(z.number().int()), + }), + ), +}); +export type SubscriptionCalendar = z.infer; + /** 手动补全某版本:不受自动补全开关与补全预算限制(仍受全局预算/缓存卷阈值) */ export const SlotFillRequest = z.object({ unitKeys: z.array(z.string().min(1)).max(500).optional() }); export type SlotFillRequest = z.infer; @@ -776,6 +850,8 @@ export const RuleGroup = EntityBase.extend({ /** 语义指纹(kind + scope + 层 + 硬排除),停用/删除后写抑制记录 */ fingerprint: z.string(), evidence: JsonObject, + /** 结构化硬条件(include / exclude 正则等),评估时并入版本目标硬条件;订阅私有组用它存表单的包含 / 排除 */ + hard: JsonObject.optional(), /** 非 null = 属于该类默认链,按此排序 */ defaultOrder: z.number().int().nullable(), /** 引用本组的订阅缺省是否自动补全其他版本;null = 不表态 */ diff --git a/packages/db/migrations/0036_subscription_private_rules.ts b/packages/db/migrations/0036_subscription_private_rules.ts new file mode 100644 index 00000000..192488d3 --- /dev/null +++ b/packages/db/migrations/0036_subscription_private_rules.ts @@ -0,0 +1,50 @@ +import type { Kysely } from "kysely"; +import { execDdl } from "../src/migration-helpers.ts"; + +// C1-A 订阅遗留收尾(docs/design/subscriptions.md §3、§6.1): +// - rule_group.hard:规则组的结构化硬条件(SlotHardConditions 子集,目前用 include / exclude 正则), +// 评估时并入版本目标硬条件。订阅表单的"包含 / 排除"写成订阅私有规则组(scope.subscriptionId), +// 不在规则页列表显示,随订阅删除(软删)。 +// - 存量:params 里已有 include / exclude 的未取消订阅各建一个私有组(params 原样保留,表单回显用)。 + +// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型 +export async function up(db: Kysely): Promise { + await execDdl( + db, + ` +alter table rule_group add column hard jsonb not null default '{}'::jsonb check (jsonb_typeof(hard) = 'object'); +create index rule_group_private_idx on rule_group ((scope ->> 'subscriptionId')) + where deleted_at is null and scope ? 'subscriptionId'; + +insert into rule_group (name, description, kind, scope, ladder_required, source, fingerprint, hard) +select '订阅私有 ' || left(s.id::text, 8) || '-' || substr(s.id::text, 10, 4), + '订阅表单的包含 / 排除,随订阅删除', + 'acquisition', + jsonb_build_object('subscriptionId', s.id::text), + false, + 'manual', + 'private:' || s.id::text || ':' || md5(coalesce(s.params -> 'include', '[]'::jsonb)::text || coalesce(s.params -> 'exclude', '[]'::jsonb)::text), + jsonb_strip_nulls(jsonb_build_object( + 'include', case when jsonb_typeof(s.params -> 'include') = 'array' and jsonb_array_length(s.params -> 'include') > 0 + then s.params -> 'include' end, + 'exclude', case when jsonb_typeof(s.params -> 'exclude') = 'array' and jsonb_array_length(s.params -> 'exclude') > 0 + then s.params -> 'exclude' end)) + from subscription s + where s.status <> 'cancelled' + and ((jsonb_typeof(s.params -> 'include') = 'array' and jsonb_array_length(s.params -> 'include') > 0) + or (jsonb_typeof(s.params -> 'exclude') = 'array' and jsonb_array_length(s.params -> 'exclude') > 0)); +`, + ); +} + +// biome-ignore lint/suspicious/noExplicitAny: 同上 +export async function down(db: Kysely): Promise { + await execDdl( + db, + ` +delete from rule_group where scope ? 'subscriptionId' and fingerprint like 'private:%'; +drop index if exists rule_group_private_idx; +alter table rule_group drop column if exists hard; +`, + ); +} diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 42820c48..432e48ab 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -1190,6 +1190,7 @@ export interface RuleGroupTable { deleted_at: Timestamptz | null; created_at: GeneratedTimestamptz; updated_at: GeneratedTimestamptz; + hard: Generated; } export interface ScheduledJobTable {