feat(识别): 集数识别分两层,只对关注中的作品拉元数据、开集数待确认
- 新增 isEntityWatched(service/watched.ts):有进行中订阅、用户搜索 / 手动识别 / 手动下载 / 人工确认身份、 正在下载或整理、已在媒体库的作品算关注中 - 自动新建作品后的 full 刷新只在关注范围内排(用户搜索 / 手动识别 / 接管照常);集数待确认事项只给关注中的作品开 - 进入关注范围(建订阅、用户搜索命中已有作品、开始下载)时对从没刷新过的作品补排刷新,刷新重算后再开事项 - 确定性的集数校验 / 换算对所有已绑定作品照做 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
731336ccb1
commit
7b7fadbfb6
7 files changed
+257
-8
No files matched your search
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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` 约束)。要彻底停掉自动写规则,撤销那条持续授权或把自动调查模式改回只读调查。
|
||||
|
||||
## 剧集组(季集结构)
|
||||
|
||||
Reference in new issue
Block a user