merge: fix/p-episode-groups
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
commit
02b9773047
35 files changed
+2751
-154
No files matched your search
@@ -0,0 +1,115 @@
|
||||
import { createMockTmdb, type MockTmdb, SPLIT_COUR_FIXTURE, TMDB_FIXTURES } from "@umt/testkit";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTmdbClient, fetchEpisodeGroup, fetchEpisodeGroups, groupSeasonAsRichSeason } from "./index.ts";
|
||||
|
||||
let mock: MockTmdb | null = null;
|
||||
afterEach(async () => {
|
||||
await mock?.close();
|
||||
mock = null;
|
||||
});
|
||||
|
||||
async function setup() {
|
||||
mock = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, SPLIT_COUR_FIXTURE] });
|
||||
const tmdb = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "test-tmdb-key", retries: 0, timeoutMs: 1000 });
|
||||
return { mock, tmdb };
|
||||
}
|
||||
|
||||
describe("TMDB 剧集组", () => {
|
||||
it("列表:id、名称、类型、组数、集数、播出平台", async () => {
|
||||
const { tmdb } = await setup();
|
||||
const groups = await fetchEpisodeGroups(tmdb, 990001, "zh-CN");
|
||||
expect(groups).toEqual([
|
||||
{
|
||||
id: "grp-production",
|
||||
name: "制作季",
|
||||
type: 6,
|
||||
description: null,
|
||||
groupCount: 2,
|
||||
episodeCount: 25,
|
||||
network: "TOKYO MX",
|
||||
},
|
||||
{
|
||||
id: "grp-aired",
|
||||
name: "播出顺序",
|
||||
type: 1,
|
||||
description: null,
|
||||
groupCount: 1,
|
||||
episodeCount: 25,
|
||||
network: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("详情:组序号 = 季号,组内顺序从 1 编集号,保留 TMDB 默认季集号、标题、播出日期", async () => {
|
||||
const { tmdb } = await setup();
|
||||
const g = await fetchEpisodeGroup(tmdb, "grp-production", "zh-CN");
|
||||
expect(g.name).toBe("制作季");
|
||||
expect(g.seasons.map((s) => [s.seasonNumber, s.name, s.episodes.length, s.airDate])).toEqual([
|
||||
[1, "第一部", 12, "2024-01-07"],
|
||||
[2, "第二部", 13, "2024-07-07"],
|
||||
]);
|
||||
const s2e1 = g.seasons[1]!.episodes[0]!;
|
||||
expect(s2e1).toMatchObject({
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 1,
|
||||
tmdbSeason: 1,
|
||||
tmdbEpisode: 13,
|
||||
name: "下篇第1话",
|
||||
nameLanguage: "zh-CN",
|
||||
airDate: "2024-07-07",
|
||||
});
|
||||
expect(g.seasons[1]!.episodes.at(-1)).toMatchObject({ episodeNumber: 13, tmdbEpisode: 25 });
|
||||
});
|
||||
|
||||
it("组内按 order 排序;重复的组序号顺延;占位名按无标题处理", async () => {
|
||||
const calls: string[] = [];
|
||||
const client = {
|
||||
getJson: async <T>(path: string) => {
|
||||
calls.push(path);
|
||||
return {
|
||||
id: "x",
|
||||
name: "X",
|
||||
type: 3,
|
||||
groups: [
|
||||
{
|
||||
id: "b",
|
||||
name: "B",
|
||||
order: 1,
|
||||
episodes: [{ order: 0, season_number: 2, episode_number: 1, name: "Opening", air_date: "2020-01-01" }],
|
||||
},
|
||||
{
|
||||
id: "a",
|
||||
name: "A",
|
||||
order: 1,
|
||||
episodes: [
|
||||
{ order: 1, season_number: 1, episode_number: 5, name: "第 5 集", air_date: "2019-02-01" },
|
||||
{ order: 0, season_number: 1, episode_number: 9, name: "Nine", air_date: "2019-01-01" },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as T;
|
||||
},
|
||||
};
|
||||
const g = await fetchEpisodeGroup(client, "x", "zh-CN");
|
||||
expect(calls).toEqual(["/tv/episode_group/x"]);
|
||||
expect(g.seasons.map((s) => [s.seasonNumber, s.name])).toEqual([
|
||||
[1, "B"],
|
||||
[2, "A"],
|
||||
]);
|
||||
expect(g.seasons[1]!.episodes.map((e) => [e.episodeNumber, e.tmdbSeason, e.tmdbEpisode, e.name])).toEqual([
|
||||
[1, 1, 9, "Nine"],
|
||||
[2, 1, 5, null],
|
||||
]);
|
||||
const rich = groupSeasonAsRichSeason(42, g.seasons[1]!, "zh-CN", "/p.jpg");
|
||||
expect(rich).toMatchObject({ tvId: "42", seasonNumber: 2, name: "A", posterPath: "/p.jpg", airDate: "2019-01-01" });
|
||||
});
|
||||
|
||||
it("剧集组不存在抛 notfound;请求走缓存", async () => {
|
||||
const { tmdb, mock } = await setup();
|
||||
await expect(fetchEpisodeGroup(tmdb, "nope")).rejects.toMatchObject({ kind: "notfound" });
|
||||
await fetchEpisodeGroups(tmdb, 990001, "zh-CN");
|
||||
const before = mock.calls.length;
|
||||
await fetchEpisodeGroups(tmdb, 990001, "zh-CN");
|
||||
expect(mock.calls.length).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* TMDB 剧集组(episode groups):一部剧在默认季之外由编辑整理的其它季集结构(按播出、DVD、制作季、故事线…)。
|
||||
* 迁移来源:MoviePilot app/modules/themoviedb/tmdbv3api/objs/tv.py episode_groups(`/tv/{id}/episode_groups`)、
|
||||
* objs/group.py details(`/tv/episode_group/{id}`)、tmdbapi.py get_tv_group_seasons(组内集号按顺序从 1 重排)、
|
||||
* get_tv_group_detail(按 order 取组 = 季);请求缓存同 client.ts(MP tmdbv3api request 的 @cached)。
|
||||
* 调整:组内集按 TMDB 给的 order 排序后再编号(MP 直接用返回顺序,两者在 TMDB 实际数据上一致);
|
||||
* 每集保留 TMDB 默认季集号(tmdbSeason / tmdbEpisode),供切换季集结构时对照迁移;
|
||||
* 占位名("第 14 集"这类按默认集号起的名字)在组里位置不同,按无标题处理。
|
||||
*/
|
||||
import type { TmdbClient } from "./client.ts";
|
||||
import type { TmdbRichEpisode, TmdbRichSeasonDetail } from "./details.ts";
|
||||
import { isPlaceholderEpisodeName } from "./translations.ts";
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
const s = (v: unknown): string | null => (typeof v === "string" && v.trim() !== "" ? v.trim() : null);
|
||||
const n = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
||||
const int = (v: unknown): number | null => {
|
||||
const x = n(v);
|
||||
return x === null ? null : Math.round(x);
|
||||
};
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}/;
|
||||
|
||||
/** TMDB 剧集组类型(type 1–7) */
|
||||
export const EPISODE_GROUP_TYPES: Readonly<Record<number, string>> = {
|
||||
1: "原始播出",
|
||||
2: "绝对集号",
|
||||
3: "DVD",
|
||||
4: "数字发行",
|
||||
5: "故事线",
|
||||
6: "制作季",
|
||||
7: "电视",
|
||||
};
|
||||
|
||||
export interface TmdbEpisodeGroupSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
/** TMDB type 1–7(见 EPISODE_GROUP_TYPES);未知为 null */
|
||||
type: number | null;
|
||||
description: string | null;
|
||||
/** 组(= 季)数 */
|
||||
groupCount: number;
|
||||
episodeCount: number;
|
||||
network: string | null;
|
||||
}
|
||||
|
||||
export interface TmdbEpisodeGroupEpisode extends TmdbRichEpisode {
|
||||
/** TMDB 默认季集号(切换季集结构时据此对照) */
|
||||
tmdbSeason: number;
|
||||
tmdbEpisode: number;
|
||||
}
|
||||
|
||||
export interface TmdbEpisodeGroupSeason {
|
||||
/** 组的 TMDB id */
|
||||
id: string | null;
|
||||
/** 季号 = 组的 order(MP 同口径) */
|
||||
seasonNumber: number;
|
||||
name: string | null;
|
||||
/** 首集播出日期 */
|
||||
airDate: string | null;
|
||||
episodes: TmdbEpisodeGroupEpisode[];
|
||||
}
|
||||
|
||||
export interface TmdbEpisodeGroupDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
type: number | null;
|
||||
description: string | null;
|
||||
network: string | null;
|
||||
seasons: TmdbEpisodeGroupSeason[];
|
||||
language: string;
|
||||
}
|
||||
|
||||
/** `/tv/{id}/episode_groups`:剧集组列表(TMDB 顺序) */
|
||||
export async function fetchEpisodeGroups(
|
||||
client: Pick<TmdbClient, "getJson">,
|
||||
tvId: string | number,
|
||||
language?: string,
|
||||
): Promise<TmdbEpisodeGroupSummary[]> {
|
||||
const j = await client.getJson<Json>(`/tv/${tvId}/episode_groups`, { language });
|
||||
return ((Array.isArray(j.results) ? j.results : []) as Json[])
|
||||
.filter((g) => s(g.id))
|
||||
.map((g) => ({
|
||||
id: s(g.id)!,
|
||||
name: s(g.name) ?? s(g.id)!,
|
||||
type: int(g.type),
|
||||
description: s(g.description),
|
||||
groupCount: int(g.group_count) ?? 0,
|
||||
episodeCount: int(g.episode_count) ?? 0,
|
||||
network: s((g.network as Json | null | undefined)?.name),
|
||||
}));
|
||||
}
|
||||
|
||||
function groupEpisode(e: Json, season: number, episode: number, lang: string): TmdbEpisodeGroupEpisode {
|
||||
const rawName = s(e.name);
|
||||
const name = rawName && !isPlaceholderEpisodeName(rawName) ? rawName : null;
|
||||
const overview = s(e.overview);
|
||||
const air = s(e.air_date);
|
||||
return {
|
||||
seasonNumber: season,
|
||||
episodeNumber: episode,
|
||||
name,
|
||||
nameLanguage: name ? lang : null,
|
||||
overview,
|
||||
overviewLanguage: overview ? lang : null,
|
||||
airDate: air && DATE_RE.test(air) ? air.slice(0, 10) : null,
|
||||
runtime: int(e.runtime),
|
||||
stillPath: s(e.still_path),
|
||||
voteAverage:
|
||||
n(e.vote_average) !== null && (int(e.vote_count) ?? 0) > 0 ? Math.round(n(e.vote_average)! * 10) / 10 : null,
|
||||
voteCount: int(e.vote_count),
|
||||
tmdbSeason: int(e.season_number) ?? 0,
|
||||
tmdbEpisode: int(e.episode_number) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `/tv/episode_group/{id}`:组 = 季(季号取 order),组内按 order 排序后从 1 编集号。
|
||||
* 同一 order 出现两次时后面的组顺延到下一个空季号(TMDB 编辑数据偶有重复)。
|
||||
*/
|
||||
export async function fetchEpisodeGroup(
|
||||
client: Pick<TmdbClient, "getJson">,
|
||||
groupId: string,
|
||||
language?: string,
|
||||
): Promise<TmdbEpisodeGroupDetail> {
|
||||
const lang = language ?? "zh-CN";
|
||||
const j = await client.getJson<Json>(`/tv/episode_group/${encodeURIComponent(groupId)}`, { language: lang });
|
||||
const groups = ((Array.isArray(j.groups) ? j.groups : []) as Json[])
|
||||
.map((g, i) => ({ g, order: int(g.order) ?? i }))
|
||||
.sort((a, b) => a.order - b.order);
|
||||
const used = new Set<number>();
|
||||
const seasons: TmdbEpisodeGroupSeason[] = [];
|
||||
for (const { g, order } of groups) {
|
||||
let season = Math.max(0, order);
|
||||
while (used.has(season)) season++;
|
||||
used.add(season);
|
||||
const eps = ((Array.isArray(g.episodes) ? g.episodes : []) as Json[])
|
||||
.map((e, i) => ({ e, order: int(e.order) ?? i }))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(({ e }, i) => groupEpisode(e, season, i + 1, lang));
|
||||
seasons.push({
|
||||
id: s(g.id),
|
||||
seasonNumber: season,
|
||||
name: s(g.name),
|
||||
airDate: eps.find((e) => e.airDate)?.airDate ?? null,
|
||||
episodes: eps,
|
||||
});
|
||||
}
|
||||
return {
|
||||
id: s(j.id) ?? groupId,
|
||||
name: s(j.name) ?? groupId,
|
||||
type: int(j.type),
|
||||
description: s(j.description),
|
||||
network: s((j.network as Json | null | undefined)?.name),
|
||||
seasons,
|
||||
language: lang,
|
||||
};
|
||||
}
|
||||
|
||||
/** 剧集组的一季 → 季详情结构(元数据刷新写季 / 集单元时与默认季同一条路径);海报由调用方补 */
|
||||
export function groupSeasonAsRichSeason(
|
||||
tvId: string | number,
|
||||
season: TmdbEpisodeGroupSeason,
|
||||
language: string,
|
||||
posterPath: string | null = null,
|
||||
): TmdbRichSeasonDetail & { episodes: TmdbEpisodeGroupEpisode[] } {
|
||||
return {
|
||||
tvId: String(tvId),
|
||||
seasonNumber: season.seasonNumber,
|
||||
name: season.name,
|
||||
nameLanguage: season.name ? language : null,
|
||||
overview: null,
|
||||
overviewLanguage: null,
|
||||
airDate: season.airDate,
|
||||
posterPath,
|
||||
episodes: season.episodes,
|
||||
fetchedLanguages: [language],
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export { RequestLimiter, TtlLruCache } from "./cache.ts";
|
||||
export * from "./client.ts";
|
||||
export * from "./details.ts";
|
||||
export * from "./episode-groups.ts";
|
||||
export * from "./match.ts";
|
||||
export * from "./recall.ts";
|
||||
export * from "./translations.ts";
|
||||
@@ -51,4 +51,33 @@ export const episodeMappingApi = {
|
||||
response: em.EpisodeMappingPreview,
|
||||
}),
|
||||
},
|
||||
episodeGroups: {
|
||||
list: ep({
|
||||
method: "get",
|
||||
path: "/media-entities/{id}/episode-groups",
|
||||
scope: "read",
|
||||
summary: "作品可选的季集结构:TMDB 剧集组列表与当前选择",
|
||||
params: IdParam,
|
||||
response: em.EpisodeGroupList,
|
||||
}),
|
||||
seasons: ep({
|
||||
method: "get",
|
||||
path: "/media-entities/{id}/episode-structure",
|
||||
scope: "read",
|
||||
summary: "某套季集结构(TMDB 默认季或剧集组)的季卡片与每集,带本地已入库情况(纯读)",
|
||||
params: IdParam,
|
||||
query: em.EpisodeStructureQuery,
|
||||
response: em.EpisodeStructureSeasons,
|
||||
}),
|
||||
set: ep({
|
||||
method: "put",
|
||||
path: "/media-entities/{id}/episode-group",
|
||||
scope: "organize.execute",
|
||||
summary:
|
||||
"切换作品的季集结构:按 TMDB 原季集原地迁移集单元(引用不动),重建季表,按作品重算已绑定资源并刷新元数据",
|
||||
params: IdParam,
|
||||
body: em.EpisodeGroupSetInput,
|
||||
response: em.EpisodeGroupSetResult,
|
||||
}),
|
||||
},
|
||||
} as const;
|
||||
@@ -25,6 +25,11 @@ export const MediaEntity = EntityBase.extend({
|
||||
lastMetadataRefreshAt: Timestamp.nullable(),
|
||||
/** 作品级命名模板覆盖 {movie?, tv?, variety?}(见 settings NamingOverride);null = 不覆盖。经 PUT /media-entities/{id}/naming-override 修改 */
|
||||
namingOverride: JsonObject.nullable().optional(),
|
||||
/** 季集结构:TMDB 剧集组 id / 名称;null = TMDB 默认季。经 PUT /media-entities/{id}/episode-group 修改 */
|
||||
episodeGroupId: z.string().nullable().optional(),
|
||||
episodeGroupName: z.string().nullable().optional(),
|
||||
episodeGroupSetAt: Timestamp.nullable().optional(),
|
||||
episodeGroupSetBy: z.string().nullable().optional(),
|
||||
});
|
||||
export type MediaEntity = z.infer<typeof MediaEntity>;
|
||||
export const MediaEntityCreate = MediaEntity.pick({
|
||||
|
||||
@@ -51,6 +51,7 @@ export const EpisodeResolutionMethod = z.enum([
|
||||
"absolute_in_season",
|
||||
"absolute_cross_season",
|
||||
"air_date",
|
||||
"tmdb_season",
|
||||
"unverified",
|
||||
"unresolved",
|
||||
"skipped",
|
||||
@@ -197,6 +198,8 @@ export const EpisodeSeasonSummary = z.object({
|
||||
),
|
||||
latestSeason: z.number().int().nullable(),
|
||||
ended: z.boolean(),
|
||||
/** 季表来自哪套季集结构:null = TMDB 默认季;否则为作品选的剧集组 */
|
||||
structure: z.object({ groupId: z.string(), name: z.string() }).nullable().optional(),
|
||||
});
|
||||
export type EpisodeSeasonSummary = z.infer<typeof EpisodeSeasonSummary>;
|
||||
|
||||
@@ -210,3 +213,119 @@ export const EpisodeMappingPreview = z.object({
|
||||
changed: z.number().int().min(0),
|
||||
});
|
||||
export type EpisodeMappingPreview = z.infer<typeof EpisodeMappingPreview>;
|
||||
|
||||
// ── 剧集组(季集结构):作品可选 TMDB 默认季或某个剧集组,季表与季 / 集单元随之生成 ──
|
||||
|
||||
export const EpisodeGroupSummary = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
/** TMDB type 1–7;未知为 null */
|
||||
type: z.number().int().nullable(),
|
||||
/** 类型的中文名(原始播出 / 绝对集号 / DVD / 数字发行 / 故事线 / 制作季 / 电视) */
|
||||
typeLabel: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
/** 组(= 季)数 */
|
||||
groupCount: z.number().int().min(0),
|
||||
episodeCount: z.number().int().min(0),
|
||||
network: z.string().nullable(),
|
||||
});
|
||||
export type EpisodeGroupSummary = z.infer<typeof EpisodeGroupSummary>;
|
||||
|
||||
/** 作品当前的季集结构;null = TMDB 默认季 */
|
||||
export const EpisodeStructureCurrent = z.object({
|
||||
groupId: z.string(),
|
||||
name: z.string(),
|
||||
setAt: z.string().nullable(),
|
||||
/** `<actor_type>:<actor_id>` */
|
||||
setBy: z.string().nullable(),
|
||||
});
|
||||
export type EpisodeStructureCurrent = z.infer<typeof EpisodeStructureCurrent>;
|
||||
|
||||
export const EpisodeGroupList = z.object({
|
||||
/** 作品是否能选剧集组(剧集 / 综艺且有 TMDB 剧集身份) */
|
||||
applicable: z.boolean(),
|
||||
current: EpisodeStructureCurrent.nullable(),
|
||||
groups: z.array(EpisodeGroupSummary),
|
||||
/** 取列表失败的原因码(TMDB 未配置 / 不可用);成功为 null */
|
||||
error: z.string().nullable(),
|
||||
});
|
||||
export type EpisodeGroupList = z.infer<typeof EpisodeGroupList>;
|
||||
|
||||
export const EpisodeStructureEpisode = z.object({
|
||||
episodeNumber: z.number().int(),
|
||||
name: z.string().nullable(),
|
||||
airDate: z.string().nullable(),
|
||||
/** TMDB 默认季集号(剧集组里才有意义;默认季时与自身相同) */
|
||||
tmdbSeason: z.number().int(),
|
||||
tmdbEpisode: z.number().int(),
|
||||
/** 本地已入库(按 TMDB 原季集对照,换结构前也能看) */
|
||||
inLibrary: z.boolean(),
|
||||
});
|
||||
export type EpisodeStructureEpisode = z.infer<typeof EpisodeStructureEpisode>;
|
||||
|
||||
export const EpisodeStructureSeason = z.object({
|
||||
seasonNumber: z.number().int(),
|
||||
name: z.string().nullable(),
|
||||
episodeCount: z.number().int().min(0),
|
||||
/** 首播日期 */
|
||||
airDate: z.string().nullable(),
|
||||
posterPath: z.string().nullable(),
|
||||
/** 本地已入库集数 */
|
||||
inLibrary: z.number().int().min(0),
|
||||
/** 当前结构下对应的季单元(选季订阅用);不是当前结构或还没建为 null */
|
||||
seasonUnitId: Id.nullable(),
|
||||
episodes: z.array(EpisodeStructureEpisode),
|
||||
});
|
||||
export type EpisodeStructureSeason = z.infer<typeof EpisodeStructureSeason>;
|
||||
|
||||
/** 某套季集结构的季卡片(TMDB 默认季或某个剧集组),带本地已入库情况 */
|
||||
export const EpisodeStructureSeasons = z.object({
|
||||
groupId: z.string().nullable(),
|
||||
name: z.string(),
|
||||
/** 是不是作品当前的季集结构 */
|
||||
current: z.boolean(),
|
||||
seasons: z.array(EpisodeStructureSeason),
|
||||
});
|
||||
export type EpisodeStructureSeasons = z.infer<typeof EpisodeStructureSeasons>;
|
||||
|
||||
export const EpisodeStructureQuery = z.object({
|
||||
/** 剧集组 id;不填 = TMDB 默认季 */
|
||||
groupId: z.string().trim().min(1).max(64).optional(),
|
||||
});
|
||||
export type EpisodeStructureQuery = z.infer<typeof EpisodeStructureQuery>;
|
||||
|
||||
export const EpisodeGroupSetInput = z.object({
|
||||
/** 剧集组 id;null = 改回 TMDB 默认季 */
|
||||
groupId: z.string().trim().min(1).max(64).nullable(),
|
||||
});
|
||||
export type EpisodeGroupSetInput = z.infer<typeof EpisodeGroupSetInput>;
|
||||
|
||||
/** 切换时对不上的集单元(保留原号、标临时) */
|
||||
export const EpisodeGroupUnmatchedUnit = z.object({
|
||||
unitId: Id,
|
||||
seasonNumber: z.number().int().nullable(),
|
||||
episodeNumber: z.number().int().nullable(),
|
||||
/** no_origin:求不出 TMDB 原季集;not_in_target:目标结构里没有这一集;numbering_locked:编号已人工锁定 */
|
||||
reason: z.enum(["no_origin", "not_in_target", "numbering_locked"]),
|
||||
/** 有资产覆盖 / 资源 / 下载等引用 */
|
||||
referenced: z.boolean(),
|
||||
});
|
||||
export type EpisodeGroupUnmatchedUnit = z.infer<typeof EpisodeGroupUnmatchedUnit>;
|
||||
|
||||
export const EpisodeGroupSetResult = z.object({
|
||||
changed: z.boolean(),
|
||||
current: EpisodeStructureCurrent.nullable(),
|
||||
/** 改了季集号的集单元数 */
|
||||
moved: z.number().int().min(0),
|
||||
/** 季集号不变的集单元数 */
|
||||
kept: z.number().int().min(0),
|
||||
unmatched: z.array(EpisodeGroupUnmatchedUnit),
|
||||
/** 新结构的季与集数 */
|
||||
seasons: z.array(z.object({ seasonNumber: z.number().int(), episodeCount: z.number().int().min(0) })),
|
||||
/** 不在新结构里、标为临时的季单元的季号 */
|
||||
orphanSeasons: z.array(z.number().int()),
|
||||
reresolve: EpisodeReresolveResult,
|
||||
/** 切换后元数据刷新结果(refreshed / skipped / failed) */
|
||||
refresh: z.string(),
|
||||
});
|
||||
export type EpisodeGroupSetResult = z.infer<typeof EpisodeGroupSetResult>;
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { execDdl } from "../src/migration-helpers.ts";
|
||||
|
||||
// 作品的季集结构(剧集组):null = TMDB 默认季;否则为 TMDB 剧集组 id(/tv/episode_group/{id}),季表与季 / 集单元按组生成。
|
||||
// 名称冗余存一份(界面显示不用再查 TMDB);选择时间与操作者(`<actor_type>:<actor_id>`)供审计与界面「谁改的」。
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`
|
||||
alter table media_entity add column episode_group_id text;
|
||||
alter table media_entity add column episode_group_name text;
|
||||
alter table media_entity add column episode_group_set_at timestamptz;
|
||||
alter table media_entity add column episode_group_set_by text;
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`
|
||||
alter table media_entity drop column if exists episode_group_set_by;
|
||||
alter table media_entity drop column if exists episode_group_set_at;
|
||||
alter table media_entity drop column if exists episode_group_name;
|
||||
alter table media_entity drop column if exists episode_group_id;
|
||||
`,
|
||||
);
|
||||
}
|
||||
@@ -953,6 +953,10 @@ export interface MediaEntityTable {
|
||||
created_at: GeneratedTimestamptz;
|
||||
updated_at: GeneratedTimestamptz;
|
||||
naming_override: Json | null;
|
||||
episode_group_id: string | null;
|
||||
episode_group_name: string | null;
|
||||
episode_group_set_at: Timestamptz | null;
|
||||
episode_group_set_by: string | null;
|
||||
}
|
||||
|
||||
export interface MediaServerInstanceTable {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { cumulativeSeasons, seasonOf } from "./table.ts";
|
||||
import type { CanonicalEpisodes, SeasonTable } from "./types.ts";
|
||||
import { validateEpisodes } from "./validate.ts";
|
||||
|
||||
export type ConversionMethod = "absolute_in_season" | "absolute_cross_season" | "air_date";
|
||||
export type ConversionMethod = "absolute_in_season" | "absolute_cross_season" | "air_date" | "tmdb_season";
|
||||
|
||||
export interface ConversionCandidate {
|
||||
method: ConversionMethod;
|
||||
@@ -88,6 +88,33 @@ export function airDateCandidates(airDate: string, table: SeasonTable, season: n
|
||||
return hits.map((canonical) => ({ method: "air_date", canonical, evidence: `air_date:${iso}` }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 剧集组结构下按 TMDB 默认季集换算:把发布者的季集当作 TMDB 默认季集,经季表的 origins 对照到组内季集。
|
||||
* 区间内每一集都要能对上,且落在同一季、组内集号连续,否则不成立。季 0 不换。
|
||||
*/
|
||||
export function tmdbSeasonCandidate(
|
||||
season: number,
|
||||
episodes: EpisodeRange,
|
||||
table: SeasonTable,
|
||||
): ConversionCandidate | null {
|
||||
const origins = table.origins;
|
||||
if (!origins?.length || season < 1 || episodes.end - episodes.start > 200) return null;
|
||||
const hits: { season: number; episode: number }[] = [];
|
||||
for (let e = episodes.start; e <= episodes.end; e++) {
|
||||
const o = origins.find((x) => x.tmdbSeason === season && x.tmdbEpisode === e);
|
||||
if (!o) return null;
|
||||
hits.push(o);
|
||||
}
|
||||
const first = hits[0]!;
|
||||
if (hits.some((h, i) => h.season !== first.season || h.episode !== first.episode + i)) return null;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return {
|
||||
method: "tmdb_season",
|
||||
canonical: { season: first.season, episodes: { start: first.episode, end: hits.at(-1)!.episode } },
|
||||
evidence: `tmdb_season:S${pad(season)}E${pad(episodes.start)}`,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConversionOutcome {
|
||||
/** 去重后、校验为 pass 的候选 */
|
||||
candidates: ConversionCandidate[];
|
||||
@@ -117,6 +144,10 @@ export function convertEpisodes(input: {
|
||||
if (cross.kind === "split") evidence.push(cross.evidence);
|
||||
}
|
||||
}
|
||||
if (episodes && season !== null) {
|
||||
const origin = tmdbSeasonCandidate(season, episodes, table);
|
||||
if (origin) raw.push(origin);
|
||||
}
|
||||
if (input.variety && airDate) {
|
||||
raw.push(...airDateCandidates(airDate, table, seasonInferred ? null : season));
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export {
|
||||
convertEpisodes,
|
||||
crossSeasonCandidate,
|
||||
inSeasonCandidates,
|
||||
tmdbSeasonCandidate,
|
||||
} from "./convert.ts";
|
||||
export { CONVERSION_NEAR_DAYS, resolveEpisodes } from "./resolve.ts";
|
||||
export { applyRule, type RuleApplication, ruleSpecificity, selectRule } from "./rules.ts";
|
||||
|
||||
@@ -13,9 +13,22 @@ export interface SeasonInfo {
|
||||
episodes?: SeasonEpisodeInfo[];
|
||||
}
|
||||
|
||||
/** 剧集组结构下一集的 TMDB 默认季集 → 组内季集 */
|
||||
export interface SeasonOrigin {
|
||||
tmdbSeason: number;
|
||||
tmdbEpisode: number;
|
||||
season: number;
|
||||
episode: number;
|
||||
}
|
||||
|
||||
/** 作品的季表(来自元数据,调用方组装)。季 0(特别篇)可以在表里,换算时忽略 */
|
||||
export interface SeasonTable {
|
||||
seasons: SeasonInfo[];
|
||||
/**
|
||||
* 作品选了 TMDB 剧集组时:每集的 TMDB 默认季集与组内季集对照。发布者按 TMDB 默认季编号(S01E14)而作品按组分季时,
|
||||
* 换算一步按它把原号换成组内季集(方式 tmdb_season)。默认季结构不给。
|
||||
*/
|
||||
origins?: SeasonOrigin[];
|
||||
/** 最新季;null 时取表中最大季号 */
|
||||
latestSeason: number | null;
|
||||
/** 作品已完结:完结后不再有「元数据滞后」放行 */
|
||||
@@ -58,6 +71,7 @@ export type EpisodeResolutionMethod =
|
||||
| "absolute_in_season"
|
||||
| "absolute_cross_season"
|
||||
| "air_date"
|
||||
| "tmdb_season"
|
||||
| "unverified"
|
||||
| "unresolved"
|
||||
| "skipped";
|
||||
|
||||
@@ -280,3 +280,39 @@ describe("resolveEpisodes 规则", () => {
|
||||
expect(r).toMatchObject({ method: "unresolved", ruleFailed: "x" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveEpisodes 剧集组:按 TMDB 默认季集换算", () => {
|
||||
// TMDB 默认 S1 共 25 集;剧集组拆成 S1(1–12)、S2(13–25)
|
||||
const grouped: SeasonTable = {
|
||||
...table([12, 13]),
|
||||
origins: Array.from({ length: 25 }, (_, k) => ({
|
||||
tmdbSeason: 1,
|
||||
tmdbEpisode: k + 1,
|
||||
season: k < 12 ? 1 : 2,
|
||||
episode: k < 12 ? k + 1 : k - 11,
|
||||
})),
|
||||
};
|
||||
|
||||
it("S01E14(按 TMDB 默认季编号)→ 组内 S02E02", () => {
|
||||
const r = run({ season: 1, episodes: ep(14) }, grouped);
|
||||
expect(r).toMatchObject({ method: "tmdb_season", canonical: { season: 2, episodes: ep(2) } });
|
||||
expect(r.evidence).toContain("tmdb_season:S01E14");
|
||||
});
|
||||
|
||||
it("「- 13」季号推定:跨季绝对集号与默认季对照同解,归第 2 季第 1 集", () => {
|
||||
const r = run({ season: 1, seasonInferred: true, episodes: ep(13) }, grouped);
|
||||
expect(r.canonical).toEqual({ season: 2, episodes: ep(1) });
|
||||
expect(["absolute_cross_season", "tmdb_season"]).toContain(r.method);
|
||||
});
|
||||
|
||||
it("区间跨组不换;组内号本身对得上时原号优先", () => {
|
||||
expect(run({ season: 1, episodes: ep(12, 13) }, grouped).method).toBe("unresolved");
|
||||
expect(run({ season: 2, episodes: ep(3) }, grouped)).toMatchObject({ method: "raw" });
|
||||
expect(run({ season: 1, episodes: ep(14, 15) }, grouped).canonical).toEqual({ season: 2, episodes: ep(2, 3) });
|
||||
});
|
||||
|
||||
it("没有 origins(默认季结构)时不出这种换算", () => {
|
||||
const { origins: _o, ...plain } = grouped;
|
||||
expect(run({ season: 1, episodes: ep(14) }, plain).method).toBe("unresolved");
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,17 @@ export interface TmdbFixture {
|
||||
seasons?: TmdbFixtureSeason[];
|
||||
/** 海报路径(拼 image.tmdb.org);缺省 null */
|
||||
poster_path?: string | null;
|
||||
/** 剧集组(`/tv/{id}/episode_groups`、`/tv/episode_group/{id}`);组内集按 TMDB 默认季集号引用 seasons 里的集 */
|
||||
episode_groups?: TmdbFixtureEpisodeGroup[];
|
||||
}
|
||||
|
||||
export interface TmdbFixtureEpisodeGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
type: number;
|
||||
description?: string;
|
||||
network?: string | null;
|
||||
groups: { id?: string; name: string; order: number; episodes: { season_number: number; episode_number: number }[] }[];
|
||||
}
|
||||
|
||||
function eps(n: number, start: string, zh: (i: number) => string, en: (i: number) => string): TmdbFixtureEpisode[] {
|
||||
@@ -374,3 +385,90 @@ export const HEART_SIGNAL_FIXTURE: TmdbFixture = {
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 两个 cour 被 TMDB 默认季合成一季的番剧(剧集组测试用):默认 S1 共 25 集(含季 0 特别篇 1 集);
|
||||
* 剧集组「制作季」把它拆成 S1(1–12)、S2(13–25),不含特别篇;剧集组「播出顺序」把全部 25 集放在一季。
|
||||
* 不在 TMDB_FIXTURES 里,用到的测试自行拼进 fixtures。
|
||||
*/
|
||||
export const SPLIT_COUR_FIXTURE: TmdbFixture = {
|
||||
type: "tv",
|
||||
id: 990001,
|
||||
original_title: "Split Cour",
|
||||
original_language: "ja",
|
||||
origin_country: ["JP"],
|
||||
date: "2024-01-07",
|
||||
titles: { "zh-CN": "分割番", "en-US": "Split Cour" },
|
||||
overviews: { "zh-CN": "两个 cour 的番剧。" },
|
||||
popularity: 30,
|
||||
alternative_titles: [],
|
||||
external_ids: { imdb_id: null, tvdb_id: null },
|
||||
rich: { genres: ["动画"], status: "Ended" },
|
||||
poster_path: "/split.jpg",
|
||||
seasons: [
|
||||
{
|
||||
season_number: 0,
|
||||
air_date: "2024-12-25",
|
||||
names: { "zh-CN": "特别篇", "en-US": "Specials" },
|
||||
episodes: [{ episode_number: 1, air_date: "2024-12-25", names: { "zh-CN": "圣诞特别篇", "en-US": "Xmas" } }],
|
||||
},
|
||||
{
|
||||
season_number: 1,
|
||||
air_date: "2024-01-07",
|
||||
names: { "zh-CN": "第 1 季", "en-US": "Season 1" },
|
||||
poster_path: "/split-s1.jpg",
|
||||
episodes: [
|
||||
...eps(
|
||||
12,
|
||||
"2024-01-07",
|
||||
(i) => `上篇第${i}话`,
|
||||
(i) => `Part1 Ep${i}`,
|
||||
),
|
||||
...eps(
|
||||
13,
|
||||
"2024-07-07",
|
||||
(i) => `下篇第${i}话`,
|
||||
(i) => `Part2 Ep${i}`,
|
||||
).map((e, k) => ({
|
||||
...e,
|
||||
episode_number: 13 + k,
|
||||
})),
|
||||
],
|
||||
},
|
||||
],
|
||||
episode_groups: [
|
||||
{
|
||||
id: "grp-production",
|
||||
name: "制作季",
|
||||
type: 6,
|
||||
network: "TOKYO MX",
|
||||
groups: [
|
||||
{
|
||||
id: "g1",
|
||||
name: "第一部",
|
||||
order: 1,
|
||||
episodes: Array.from({ length: 12 }, (_, k) => ({ season_number: 1, episode_number: k + 1 })),
|
||||
},
|
||||
{
|
||||
id: "g2",
|
||||
name: "第二部",
|
||||
order: 2,
|
||||
episodes: Array.from({ length: 13 }, (_, k) => ({ season_number: 1, episode_number: 13 + k })),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "grp-aired",
|
||||
name: "播出顺序",
|
||||
type: 1,
|
||||
groups: [
|
||||
{
|
||||
id: "a1",
|
||||
name: "全集",
|
||||
order: 1,
|
||||
episodes: Array.from({ length: 25 }, (_, k) => ({ season_number: 1, episode_number: k + 1 })),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -19,7 +19,7 @@ const notFound: MockReply = {
|
||||
json: { success: false, status_code: 34, status_message: "The resource you requested could not be found." },
|
||||
};
|
||||
|
||||
/** mock TMDB v3:/3/search/{movie,tv}、/3/find/:imdbId、/3/{movie,tv}/:id(append_to_response)、/3/tv/:id/season/:n */
|
||||
/** mock TMDB v3:/3/search/{movie,tv}、/3/find/:imdbId、/3/{movie,tv}/:id(append_to_response)、/3/tv/:id/season/:n、/3/tv/:id/episode_groups、/3/tv/episode_group/:id */
|
||||
export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise<MockTmdb> {
|
||||
const fx = opts.fixtures ?? TMDB_FIXTURES;
|
||||
const apiKey = opts.apiKey ?? "test-tmdb-key";
|
||||
@@ -101,6 +101,72 @@ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise<MockTm
|
||||
};
|
||||
},
|
||||
],
|
||||
[
|
||||
/^\/3\/tv\/(\d+)\/episode_groups$/,
|
||||
(m) => {
|
||||
const f = find("tv", m[1]!);
|
||||
if (!f) return notFound;
|
||||
return {
|
||||
json: {
|
||||
id: f.id,
|
||||
results: (f.episode_groups ?? []).map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
type: g.type,
|
||||
description: g.description ?? "",
|
||||
group_count: g.groups.length,
|
||||
episode_count: g.groups.reduce((a, x) => a + x.episodes.length, 0),
|
||||
network: g.network ? { id: 1, name: g.network } : null,
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
],
|
||||
[
|
||||
/^\/3\/tv\/episode_group\/([\w-]+)$/,
|
||||
(m, q) => {
|
||||
const owner = fx.find((f) => f.episode_groups?.some((g) => g.id === m[1]));
|
||||
const g = owner?.episode_groups?.find((x) => x.id === m[1]);
|
||||
if (!owner || !g) return notFound;
|
||||
const lang = q.language ?? "en-US";
|
||||
const zh = lang.startsWith("zh");
|
||||
return {
|
||||
json: {
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
type: g.type,
|
||||
description: g.description ?? "",
|
||||
group_count: g.groups.length,
|
||||
episode_count: g.groups.reduce((a, x) => a + x.episodes.length, 0),
|
||||
network: g.network ? { id: 1, name: g.network } : null,
|
||||
groups: g.groups.map((x, gi) => ({
|
||||
id: x.id ?? `${g.id}-${gi}`,
|
||||
name: x.name,
|
||||
order: x.order,
|
||||
locked: true,
|
||||
episodes: x.episodes.map((ref, order) => {
|
||||
const s = owner.seasons?.find((y) => y.season_number === ref.season_number);
|
||||
const e = s?.episodes.find((y) => y.episode_number === ref.episode_number);
|
||||
return {
|
||||
id: owner.id * 10000 + ref.season_number * 1000 + ref.episode_number,
|
||||
show_id: owner.id,
|
||||
order,
|
||||
season_number: ref.season_number,
|
||||
episode_number: ref.episode_number,
|
||||
name: e?.names[lang] ?? (zh ? `第 ${ref.episode_number} 集` : `Episode ${ref.episode_number}`),
|
||||
overview: e?.overviews?.[lang] ?? "",
|
||||
air_date: e?.air_date ?? null,
|
||||
runtime: e?.runtime ?? null,
|
||||
still_path: e?.still_path ?? null,
|
||||
vote_average: e?.vote_average ?? 0,
|
||||
vote_count: e?.vote_count ?? 0,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
],
|
||||
[
|
||||
/^\/3\/tv\/(\d+)\/season\/(\d+)$/,
|
||||
(m, q) => {
|
||||
|
||||
Reference in new issue
Block a user