diff --git a/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts b/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts index 4aed224c..9b5bcb49 100644 --- a/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts +++ b/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts @@ -14,6 +14,7 @@ import type { SubscriptionRow } from "@umt/db"; import type { Context } from "hono"; import type { AuthEnv } from "../../../http/auth.ts"; import { ApiException } from "../../../http/errors.ts"; +import { enqueueWatchedRefresh } from "../../sources-identity/service/watched.ts"; import type { ModuleApp } from "../../types.ts"; import { ensureSubscriptionSlot } from "../services/acquisition.ts"; import type { AoDeps, Exec } from "../services/common.ts"; @@ -378,6 +379,8 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) { return r; }); if (row.media_entity_id) { + // 建订阅 = 作品进入关注范围:从没刷新过元数据的补排 full 刷新(集数两层,见 sources-identity/service/watched.ts) + await enqueueWatchedRefresh(d.ctx, row.media_entity_id, "subscription"); await kickEvaluate(d, { subscriptionId: row.id }); // 新订阅:立即主动搜索一次(之后按周期);剧集先刷新元数据拿季总集数 await enqueueSubscriptionSearch(d, row.id, "created").catch((err) => diff --git a/apps/server/src/modules/acquisition-organize/services/downloads.ts b/apps/server/src/modules/acquisition-organize/services/downloads.ts index 854e5553..aa572fbf 100644 --- a/apps/server/src/modules/acquisition-organize/services/downloads.ts +++ b/apps/server/src/modules/acquisition-organize/services/downloads.ts @@ -35,6 +35,7 @@ import { import { mergeObservedDownloadState, toDomainDownloadStatus } from "@umt/mapping"; import { withAttemptToken } from "@umt/tasks"; import { createEntityEpisodeMapper, publishedAtOf } from "../../sources-identity/service/episode-resolution.ts"; +import { enqueueWatchedRefresh } from "../../sources-identity/service/watched.ts"; import { errMessage, jsonb } from "../util.ts"; import { parsedOf } from "./acquisition.ts"; import { @@ -266,6 +267,9 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?: .selectAll() .where("id", "=", plan.source_release_id) .executeTakeFirstOrThrow(); + // 开始下载 = 作品进入关注范围:从没刷新过元数据的(自动建档时不在关注范围)补排一次 full 刷新(集数两层,见 watched.ts) + const watchedEntity = ev.mediaEntityId ?? rel.media_entity_id; + if (watchedEntity) await enqueueWatchedRefresh(d.ctx, watchedEntity, "download"); 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; diff --git a/apps/server/src/modules/sources-identity/service/episode-resolution.ts b/apps/server/src/modules/sources-identity/service/episode-resolution.ts index 72afde45..39ce0c87 100644 --- a/apps/server/src/modules/sources-identity/service/episode-resolution.ts +++ b/apps/server/src/modules/sources-identity/service/episode-resolution.ts @@ -25,6 +25,7 @@ import { jsonb } from "../rows.ts"; import { closeDecisions, type Exec, openDecision } from "./common.ts"; import { ensureUnits } from "./entities.ts"; import { seasonOrigins } from "./episode-structure.ts"; +import { isEntityWatched } from "./watched.ts"; // 集数解析接入(docs/plan/episode-resolution.md 批次 2):识别绑定作品后、建集单元前,按作品季表 + 映射规则把 // 「发布者声称的季集」换成「媒体库应当使用的季集」。判定本身在 domain resolveEpisodes,这里只装配数据与落库: @@ -274,6 +275,9 @@ export async function settleEpisodeDecision( : null; await detachFromEpisodeDecisions(ctx, exec, sourceReleaseId, target); if (!target) return; + // 两层(2026-10-06):集数待确认是内部事项(供智能体调查,用户在订阅详情 / 作品页集数映射里看原因), + // 只给关注中的作品开;不在关注范围的作品资源照样待确认(不建集、订阅跳过),进入关注范围补刷新时重算再开 + if (!(await isEntityWatched(exec, entityId))) return; const existing = await exec .selectFrom("decision") diff --git a/apps/server/src/modules/sources-identity/service/resolve.ts b/apps/server/src/modules/sources-identity/service/resolve.ts index b7018efc..280a60cf 100644 --- a/apps/server/src/modules/sources-identity/service/resolve.ts +++ b/apps/server/src/modules/sources-identity/service/resolve.ts @@ -55,6 +55,7 @@ import { import { loadSubject, searchBangumi } from "./meta-subjects.ts"; import { tmdbRecallInput, tmdbToCandidate } from "./tmdb-candidate.ts"; import { loadRecognizeCache, type RecognizeMeta, recognizeMetaOf, saveRecognizeCache } from "./tmdb-recognize-cache.ts"; +import { enqueueWatchedRefresh, isEntityWatched } from "./watched.ts"; // resolveObservation:parseRelease → 本地召回(别名 pg_trgm + 已有外部身份 + 人工锁)→ [按需] TMDB 候选 → domain.resolveIdentity。 // auto 才建/关联正式作品;ambiguous / no_match / 查询失败进待识别池(候选只存 JSON),有界退避复查。 @@ -900,7 +901,14 @@ async function bindAuto( reason: resolution.reason, }; }); - if (created) await enqueueNewEntityRefresh(ctx, created); + // 两层(2026-10-06):自动新建的作品只在关注范围内才排刷新(用户搜索 / 手动识别 / 接管下载,或已被订阅等); + // RSS / 列表扫进来、没人关心的作品不拉 TMDB 季表,等它进入关注范围(建订阅、搜索、开始下载)时再补排 + const userAsked = lookup === "search" || lookup === "manual" || lookup === "takeover"; + if (created) { + if (userAsked || (await isEntityWatched(ctx.db, created))) await enqueueNewEntityRefresh(ctx, created); + } else if (userAsked && result.mediaEntityId) { + await enqueueWatchedRefresh(ctx, result.mediaEntityId, `lookup:${lookup}`); + } return result; } catch (e) { if (e instanceof IdentityConflictError && attempt < 2) continue; diff --git a/apps/server/src/modules/sources-identity/service/watched.ts b/apps/server/src/modules/sources-identity/service/watched.ts new file mode 100644 index 00000000..13cb7e67 --- /dev/null +++ b/apps/server/src/modules/sources-identity/service/watched.ts @@ -0,0 +1,70 @@ +import { sql } from "@umt/db"; +import type { ServerContext } from "../../types.ts"; +import type { Exec } from "./common.ts"; + +// 关注中的作品(2026-10-06,集数识别分两层): +// 确定性的集数校验 / 换算对所有已绑定作品的资源照做(无外部请求、无打扰); +// 有成本或会打扰的后续动作——拉 TMDB、自动新建作品后的元数据刷新、开集数待确认、派智能体——只对关注中的作品做。 +// +// 关注中 = 满足任一: +// 1. 有进行中的订阅(subscription.status in active / pending); +// 2. 用户明确表达过兴趣:手动下载过(acquisition_plan.evaluation.manual = true)、用户搜索 / 手动识别出的资源 +// (source_release.resolution_evidence.lookup in search / manual)、人工 / 智能体确认的外部身份(evidence_level = manual); +// 3. 正在下载或整理:未移除且没入库完的下载任务(直接、经资源或经下载计划关联),或排队 / 执行中的整理记录; +// 4. 已在媒体库:有在用(active)的已入库文件。 +// RSS / 站点列表扫进来、自动建档却没人关心的作品不算。 + +const OPEN_SUBSCRIPTION = ["active", "pending"]; +const DOWNLOAD_DONE = ["imported", "removed"]; + +export async function isEntityWatched(exec: Exec, entityId: string): Promise { + const r = await sql<{ watched: boolean }>` + select ( + exists (select 1 from subscription s where s.media_entity_id = ${entityId} and s.status = any(${OPEN_SUBSCRIPTION})) + or exists (select 1 from media_asset a where a.media_entity_id = ${entityId} and a.status = 'active') + or exists (select 1 from external_identity x + where x.media_entity_id = ${entityId} and x.status = 'accepted' and x.evidence_level = 'manual') + or exists (select 1 from source_release r + where r.media_entity_id = ${entityId} and r.resolution_evidence->>'lookup' in ('search', 'manual')) + or exists (select 1 from acquisition_plan p left join acquisition_slot sl on sl.id = p.slot_id + where (sl.media_entity_id = ${entityId} or p.evaluation->>'mediaEntityId' = ${entityId}::text) + and (p.evaluation->>'manual')::boolean is true) + or exists (select 1 from download_task t + left join source_release r on r.id = t.source_release_id + left join acquisition_plan p on p.id = t.acquisition_plan_id + left join acquisition_slot sl on sl.id = p.slot_id + where t.removed_at is null and t.status <> all(${DOWNLOAD_DONE}) + and ${entityId} in (t.media_entity_id, r.media_entity_id, sl.media_entity_id)) + or exists (select 1 from organization_record o + where o.media_entity_id = ${entityId} and o.status in ('queued', 'running')) + ) as watched`.execute(exec); + return r.rows[0]?.watched === true; +} + +/** + * 作品进入关注范围(建订阅、用户搜索、开始下载)时补排一次 full 元数据刷新:只对从没刷新过的作品排 + * (自动建档时因不在关注范围而没排的),刷新完成会重算待确认 / 滞后放行资源的集数(必要时开集数待确认)。 + * 入队失败只记日志;同一作品同一原因只排一次(幂等键)。 + */ +export async function enqueueWatchedRefresh(ctx: ServerContext, entityId: string, reason: string): Promise { + const e = await ctx.db + .selectFrom("media_entity") + .select(["id", "last_metadata_refresh_at", "status"]) + .where("id", "=", entityId) + .executeTakeFirst(); + if (!e || e.last_metadata_refresh_at || e.status === "merged") return false; + try { + await ctx.engine.enqueue( + "refreshMetadata", + { mediaEntityId: entityId, full: true }, + { idempotencyKey: `refreshMetadata:watched:${entityId}`, traceId: entityId }, + ); + return true; + } catch (err) { + ctx.logger.debug( + { entityId, reason, err: { message: err instanceof Error ? err.message : String(err) } }, + "进入关注范围的元数据刷新入队失败", + ); + return false; + } +} diff --git a/apps/server/test/sources-identity/episode-watch-scope.test.ts b/apps/server/test/sources-identity/episode-watch-scope.test.ts new file mode 100644 index 00000000..9b707613 --- /dev/null +++ b/apps/server/test/sources-identity/episode-watch-scope.test.ts @@ -0,0 +1,150 @@ +import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture } from "@umt/testkit"; +import { sql } from "kysely"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts"; +import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts"; +import { enqueueWatchedRefresh, isEntityWatched } from "../../src/modules/sources-identity/service/watched.ts"; +import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts"; + +// 集数识别分两层(2026-10-06): +// 确定性校验 / 换算对所有已绑定作品的资源照做;拉 TMDB(自动建档后的元数据刷新)、开集数待确认只对关注中的作品做。 +// RSS 扫进来自动建档、没人关心的作品:不排刷新、不开事项;进入关注范围(建订阅等)补排刷新,刷新重算后才开内部事项。 + +const pg = await pgAvailable(); + +const season = (n: number, eps: number, start: string) => ({ + season_number: n, + air_date: start, + names: { "zh-CN": `第 ${n} 季`, "en-US": `Season ${n}` }, + episodes: Array.from({ length: eps }, (_, k) => ({ + episode_number: k + 1, + air_date: new Date(Date.parse(start) + k * 7 * 86_400_000).toISOString().slice(0, 10), + names: { "zh-CN": `第 ${k + 1} 集`, "en-US": `Episode ${k + 1}` }, + })), +}); + +// 已完结:S1 = 12、S2 = 13;绝对集号 40 超出全部季,刷新后换算不了 → 集数待确认 +const SHOW: TmdbFixture = { + type: "tv", + id: 990301, + original_title: "Yoru no Kurage wa Oyogenai", + original_language: "ja", + origin_country: ["JP"], + date: "2024-04-06", + titles: { "zh-CN": "夜晚的水母不会游泳", "en-US": "Yoru no Kurage wa Oyogenai" }, + popularity: 30, + alternative_titles: [], + external_ids: {}, + rich: { status: "Ended" }, + seasons: [season(1, 12, "2024-04-06"), season(2, 13, "2025-04-05")], +}; + +describe.skipIf(!pg)("集数识别两层:只对关注中的作品拉元数据、开集数待确认", () => { + let h: Harness; + let tmdb: MockTmdb; + let siteId: string; + let n = 0; + + beforeAll(async () => { + tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, SHOW] }); + h = await createHarness("episode_watch_scope", { tmdbUrl: tmdb.url }); + siteId = (await seedSite(h, { baseUrl: "https://acgrip.example/" })).site.id; + // 不靠订阅也查 TMDB(旧行为):RSS 扫进来的资源会自动建档,但作品不在关注范围 + await sql`insert into settings (key, value) values ('identity', ${JSON.stringify({ eagerTmdbLookup: true })}::jsonb) + on conflict (key) do update set value = excluded.value`.execute(h.db); + }); + afterAll(async () => { + await h?.close(); + await tmdb?.close(); + }); + + const observe = async (title: string) => { + const r = await h.db + .insertInto("source_release") + .values({ site_id: siteId, site_release_id: String(830000 + ++n), title, subtitle: "夜晚的水母不会游泳" }) + .returningAll() + .executeTakeFirstOrThrow(); + const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "observed" }); + expect(res.outcome, JSON.stringify(res)).toBe("auto"); + return { id: r.id, entityId: res.mediaEntityId as string }; + }; + const method = async (id: string) => + ( + ( + await h.db + .selectFrom("source_release") + .select("episode_resolution") + .where("id", "=", id) + .executeTakeFirstOrThrow() + ).episode_resolution as { method: string } + ).method; + const refreshJobs = () => h.engine.enqueued.filter((e) => e.task === "refreshMetadata"); + const episodeDecisions = () => + h.db + .selectFrom("decision") + .selectAll() + .where("kind", "=", "episode_mapping") + .where("status", "in", ["open", "investigating", "awaiting_user"]) + .execute(); + + let entityId: string; + let r40: string; + + it("RSS 扫进来自动建档:确定性换算照做,但不排元数据刷新、不开集数待确认", async () => { + const a = await observe("[ANi] Yoru no Kurage wa Oyogenai - 03 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]"); + entityId = a.entityId; + expect(await method(a.id)).toBe("unverified"); + expect(await isEntityWatched(h.db, entityId)).toBe(false); + const b = await observe("[Lilith-Raws] Yoru no Kurage wa Oyogenai - 40 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]"); + r40 = b.id; + expect(b.entityId).toBe(entityId); + expect(refreshJobs()).toEqual([]); + expect(await episodeDecisions()).toEqual([]); + // 季详情一次都没拉(识别只用搜索与详情) + expect(tmdb.callsTo(/\/season\//)).toHaveLength(0); + }); + + it("即使季表已知、资源换算不了,非关注作品也不开事项", async () => { + // 直接刷新一次(模拟别处拉过季表),非关注作品的待确认资源照样只记在资源上 + expect((await refreshMetadata(h.rt, entityId, { full: true })).status).toBe("refreshed"); + expect(await method(r40)).toBe("unresolved"); + expect(await episodeDecisions()).toEqual([]); + await h.db.updateTable("media_entity").set({ last_metadata_refresh_at: null }).where("id", "=", entityId).execute(); + }); + + it("进入关注范围(建订阅):补排一次 full 刷新;刷新重算后对换算不了的资源开内部事项", async () => { + await h.db + .insertInto("subscription") + .values({ media_entity_id: entityId, media_kind: "series", status: "active" }) + .execute(); + expect(await isEntityWatched(h.db, entityId)).toBe(true); + expect(await enqueueWatchedRefresh(h.ctx, entityId, "subscription")).toBe(true); + expect(refreshJobs()).toEqual([ + { + task: "refreshMetadata", + input: { mediaEntityId: entityId, full: true }, + key: `refreshMetadata:watched:${entityId}`, + }, + ]); + // 执行入队的刷新(同 refreshMetadata 任务处理器) + expect((await refreshMetadata(h.rt, entityId, { full: true })).status).toBe("refreshed"); + const ds = await episodeDecisions(); + expect(ds).toHaveLength(1); + expect((ds[0]!.evidence as { sourceReleaseIds: string[] }).sourceReleaseIds).toEqual([r40]); + // 已刷新过的不再补排 + expect(await enqueueWatchedRefresh(h.ctx, entityId, "download")).toBe(false); + }); + + it("用户搜索 / 手动识别出的新作品照常排刷新", async () => { + const r = await h.db + .insertInto("source_release") + .values({ site_id: siteId, site_release_id: String(830000 + ++n), title: "Oppenheimer.2023.2160p.UHD.BluRay" }) + .returningAll() + .executeTakeFirstOrThrow(); + const before = refreshJobs().length; + const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" }); + expect(res.outcome, JSON.stringify(res)).toBe("auto"); + expect(await isEntityWatched(h.db, res.mediaEntityId as string)).toBe(true); + expect(refreshJobs().length).toBe(before + 1); + }); +}); diff --git a/docs/runbook/identity.md b/docs/runbook/identity.md index 0028aeb7..777fe017 100644 --- a/docs/runbook/identity.md +++ b/docs/runbook/identity.md @@ -1,7 +1,8 @@ # 资源识别与待识别池运行手册(DEC-1) 新观察到的资源(RSS、站点列表快照、搜索结果)先进待识别池,只在有理由时才查 TMDB。只有和订阅或本地作品相关、 -又需要你决定的资源才进待处理。对照 MoviePilot:RSS / 站点资源只在匹配订阅或用户搜索时才识别,识别失败只写日志、不告警 +又需要你决定的资源才开内部事项(2026-10-06 起待处理中心取消:搜索 / RSS 扫进来的未识别资源静默留在「未识别」里,不提示, +要处理的在媒体库「未识别」里选候选或手动指定)。对照 MoviePilot:RSS / 站点资源只在匹配订阅或用户搜索时才识别,识别失败只写日志、不告警 (`app/chain/subscribe/match.py` `_prepare_match_torrents`:未识别的种子最多重试 3 次,之后跳过)。 需求依据:REQ §4.2–4.3(观察不等于事实)、§6.3(新作品收录 / 追更补缺 / 升级都由规则触发)、§7.5(待识别池可检索、 @@ -139,18 +140,27 @@ select key, value->>'title' title, value->>'year' y, value->>'source' src from k ## 集数映射怎么用(集数解析批次 4) -资源绑定到剧集 / 综艺后,先按作品季表自动判定「媒体库该用哪一集」:原号对得上就用原号;元数据还没覆盖到(最新季未完结、总集数未知)先按原号放行;否则试季内 / 跨季绝对集号、按播出日期换算,唯一解才采用。都不行就是**集数待确认**:资源照常绑定作品,但不建集、订阅跳过、整理不按原号入库,并按「作品 + 来源季 + 发布组」开一条待处理。判定细节见 `docs/plan/episode-resolution.md`。 +资源绑定到剧集 / 综艺后,先按作品季表自动判定「媒体库该用哪一集」:原号对得上就用原号;元数据还没覆盖到(最新季未完结、总集数未知)先按原号放行;否则试季内 / 跨季绝对集号、按播出日期换算,唯一解才采用。都不行就是**集数待确认**:资源照常绑定作品,但不建集、订阅跳过、整理不按原号入库,并按「作品 + 来源季 + 发布组」开一条内部事项(只给关注中的作品开,见下面「两层」)。判定细节见 `docs/plan/episode-resolution.md`。 + +**两层(2026-10-06)**:确定性校验 / 换算对所有已绑定作品的资源照做(无外部请求、无打扰);有成本或打扰的后续动作——拉 TMDB、 +自动新建作品后的元数据刷新、开集数待确认事项、派智能体——只对**关注中**的作品做(`isEntityWatched`,`service/watched.ts`): +有进行中订阅(active / pending);用户明确搜索 / 手动识别过(资源 `resolution_evidence.lookup` 为 search / manual)、手动下载过 +(`acquisition_plan.evaluation.manual`)或人工 / 智能体确认过外部身份(evidence_level manual);正在下载(未移除、没入库完的下载任务) +或整理(排队 / 执行中的整理记录);已在媒体库(有 active 的已入库文件)。RSS / 列表扫进来自动建档、没人关心的作品不排刷新、不开事项。 +进入关注范围时补排一次 full 刷新(`enqueueWatchedRefresh`,幂等键 `refreshMetadata:watched:<作品>`,只对从没刷新过的作品): +建订阅、用户搜索 / 手动识别命中已有作品、开始下载(`submitDownload`);刷新会按作品重算待确认 / 滞后放行的资源,换算不了的这时才开事项。 +集数待确认不再是给用户逐条处理的条目:原因在订阅详情「集数映射」与作品页「集数映射」分区按组显示,建议规则 / 智能体建议在作品页一键确认。 **规则**在作品页「集数映射」分区管理(`GET/POST /media-entities/{id}/episode-mapping-rules`、`PATCH/DELETE /episode-mapping-rules/{id}`): - 作用域:作品必填,来源季、发布组可空(空 = 任意);越具体越优先。同作品 + 季 + 发布组(发布组忽略大小写)只能有一条启用的规则,冲突返回 409,先停用或修改旧的那条。 - 类型:**偏移表达式**(`EP-12`、`EP*2-1`,省略 EP 按 MP 约定补全,可指定目标季)、**对照表**(每行 `26=S02E01`,区间 `27-28=S02E02`,区间不能重叠)、**特别篇 / 花絮 / 忽略**。表达式解析不了、表格区间不合法直接拒绝。 -- 每次新建 / 修改 / 停用 / 启用 / 删除都在同一事务里按作品重算已绑定资源(人工锁定的不动),返回重算条数、变化条数、待确认转已解决条数;资源解决后对应的待处理自动关闭。 -- 规则算出的集数对不上季表时自动停用(停用原因写当时的来源季集)并重新开待处理;手动停用的原因写「手动停用」。 +- 每次新建 / 修改 / 停用 / 启用 / 删除都在同一事务里按作品重算已绑定资源(人工锁定的不动),返回重算条数、变化条数、待确认转已解决条数;资源解决后对应的事项自动关闭。 +- 规则算出的集数对不上季表时自动停用(停用原因写当时的来源季集)并重新开事项;手动停用的原因写「手动停用」。 **模拟预览**(`POST /media-entities/{id}/episode-mapping/preview`,纯读):取作品下最近 100 条已绑定资源(按发布时间倒序,可按季 / 发布组过滤;季按来源季或规范季任一匹配),按原标题重解析后逐条给出原始季集 → 库里的当前结果 → 套用草稿规则后的结果、校验状态、是否有变化;另给季表摘要、按发布组的方式统计与待确认数、各组待确认样本推出的建议规则。草稿规则会替换同作用域的已有规则(修改时用 `replaceRuleId` 替换被改的那条)。作品页边改草稿边预览,点「应用」才写入。 -**待处理「集数待确认」**:卡片列出来源季集、原因、建议规则(同组待确认资源按发布时间对播出日期推出,全部样本成立且唯一才给)与它套用到同组资源的模拟结果。 +**作品页「换算不了的资源」**(原待处理「集数待确认」卡片,2026-10-06 起显示在作品页集数映射分区):每组列出来源季集、原因、建议规则(同组待确认资源按发布时间对播出日期推出,全部样本成立且唯一才给)与它套用到同组资源的模拟结果。 - 确认:选项 `confirm_suggestion`,建议规则写成 expression 规则 → 事项按用户选择关闭 → 按作品重算(同一事务;仍对不上的资源另开新事项)。 - 修改:跳到作品页集数映射并预填草稿(`?mapSeason=&mapGroup=&mapExpr=&mapTarget=#episode-mapping`),在那里改完再应用。 @@ -160,7 +170,7 @@ select key, value->>'title' title, value->>'year' y, value->>'source' src from k **整理向导**:识别结果里的季集只是按首个本地候选映射后的预填。向导只把用户改过的季集(行内输入、批量季号、集号偏移、弹窗「指定季号」)发给服务端,当作人工指定;没改过的由服务端按最终作品映射,所以改选作品后预填会清空、改由新作品映射。识别为集数待确认的行(`episodeUnresolved`)在向导里未决,填季集或排除后才能生成预览。 -**新建作品的季表**:自动识别新建的作品(和人工指定、整理接管一样)在事务提交后排一次 full 元数据刷新(幂等键 `refreshMetadata:created:<作品>`);新建档没有季表时资源先按原号放行(unverified),刷新补齐季表后自动重算为换算结果。 +**新建作品的季表**:自动识别新建的作品在关注范围内(用户搜索 / 手动识别 / 接管下载,或已被订阅等)才在事务提交后排一次 full 元数据刷新(幂等键 `refreshMetadata:created:<作品>`),其余等进入关注范围时补排;新建档没有季表时资源先按原号放行(unverified),刷新补齐季表后自动重算为换算结果。 **智能体兜底**(批次 5):确定性流程(规则 → 原号校验 → 换算)都失败、开了「集数待确认」后,智能体在完整上下文里判断一次,产出一条结构化规则;写入后同类资源由规则自动处理,智能体不逐集调用。 @@ -168,7 +178,7 @@ select key, value->>'title' title, value->>'year' y, value->>'source' src from k - 三个能力:`episode_mapping.inspect`(只读:事项内资源、同组相邻资源各最多 50 条,季表每季最多 100 集含播出日期与集标题,现有规则含停用原因,确定性建议)→ `episode_mapping.simulate`(只读:复用模拟预览,汇总事项内样本是否全部解决、有没有让已对上的资源变成待确认)→ `episode_mapping.apply_rule`(写)。 - `apply_rule` 服务端强制再模拟:样本有一条没解决、或会让当前已对上 / 已换算的资源变成待确认,直接拒绝不写库。通过才写规则:`created_by_type = agent`,样本 ≥ 3 且全部对得上记「多证据」(`multi_evidence`),否则「待复核」(`needs_review`);证据存理由、样本数、模拟摘要与会话 ID;随后按作品重算,资源都解决时事项自动关闭,系统据此核实恢复。 - 需要什么授权:后台自动调查的模式要是「授权内恢复」(`agent.autoInvestigateMode = recover`),且有一条覆盖 `episode_mapping.apply_rule` 的有效持续授权(action 写 `episode_mapping.apply_rule` 或 `*`;可用 `scope.mediaEntityIds / mediaKinds` 限定作品,按事项的作品核对;不随身份识别策略放开)。对话里没有授权时同样被拒,智能体只会说「需要你批准」。 -- 没有授权(或模式是只读调查)时降级只读:智能体在会话里用 `simulate`(带事项 ID 和理由)试出的通过规则,会作为「智能体建议」写进事项证据(`evidence.agentSuggestion`,事项刷新时保留),待处理卡片显示规则、理由、样本套用后的季集与模拟结果,点「确认智能体建议」(选项 `confirm_agent_suggestion`)即建规则 + 重算;建之前按当前资源再模拟一次,不通过返回 409 不写。人工确认的规则记操作者为管理员、证据等级「人工确认」。 +- 没有授权(或模式是只读调查)时降级只读:智能体在会话里用 `simulate`(带事项 ID 和理由)试出的通过规则,会作为「智能体建议」写进事项证据(`evidence.agentSuggestion`,事项刷新时保留),作品页集数映射里显示规则、理由、样本套用后的季集与模拟结果,点「确认智能体建议」(选项 `confirm_agent_suggestion`)即建规则 + 重算;建之前按当前资源再模拟一次,不通过返回 409 不写。人工确认的规则记操作者为管理员、证据等级「人工确认」。 - 怎么撤销:作品页「集数映射」里智能体写的规则带「智能体 · 多证据 / 待复核」标记和依据,点「停用」:立即按作品重算(资源回到待确认并重新开事项),且同作用域有手动停用的规则时 `apply_rule` 一律拒绝,智能体不会再自动写回(仍可留建议给人确认)。直接删除不留这个记号,新开的事项可能再被自动写一次(受同根因自动恢复次数上限 `agent.investigateMaxAttempts` 约束)。要彻底停掉自动写规则,撤销那条持续授权或把自动调查模式改回只读调查。 ## 剧集组(季集结构)