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
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user