真正入库取决于媒体服务器有没有扫到,本工具只负责整理(元数据、NFO 与图片、按规则归到目录)。 界面与面向用户的服务端文案里「已入库 / 未入库 / 部分入库 / 入库失败 / 入库中 / 入库后 / 原地入库…」 改为「已整理 / 未整理 / 部分整理 / 整理失败 / 整理中 / 整理后 / 原地整理…」;文件管理快速筛选「已整理」、 目录副行「n 个已整理 / 未整理」、季卡片「已整理 x/y」。代码标识符不改,「Emby 已有」这类媒体服务器状态保留。 glossary 加「整理」口径条目;check:copy 拦处理状态语境的「入库」搭配,其它线待替换的文件暂列 TERM_PENDING。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
184 lines
6.5 KiB
TypeScript
184 lines
6.5 KiB
TypeScript
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type { EpisodeGroupList, EpisodeStructureSeasons } from "@umt/contracts";
|
|
import { createElement, type ReactElement } from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import { describe, expect, it } from "vitest";
|
|
import { episodeMappingKeys } from "~/api/episode-mapping.ts";
|
|
import {
|
|
EpisodeStructureSwitcher,
|
|
SeasonCardGrid,
|
|
StructureTrack,
|
|
SubscriptionSeasonPicker,
|
|
} from "./episode-structure.tsx";
|
|
import {
|
|
hasStructureChoice,
|
|
inLibraryText,
|
|
originText,
|
|
seasonLine,
|
|
structureOptions,
|
|
} from "./episode-structure-model.ts";
|
|
|
|
const ENTITY = "6b1f2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d";
|
|
|
|
const groups = (current: string | null): EpisodeGroupList => ({
|
|
applicable: true,
|
|
current: current ? { groupId: current, name: "制作季", setAt: null, setBy: null } : null,
|
|
groups: [
|
|
{
|
|
id: "grp-production",
|
|
name: "制作季",
|
|
type: 6,
|
|
typeLabel: "制作季",
|
|
description: null,
|
|
groupCount: 2,
|
|
episodeCount: 25,
|
|
network: null,
|
|
},
|
|
{
|
|
id: "grp-aired",
|
|
name: "播出顺序",
|
|
type: 1,
|
|
typeLabel: "原始播出",
|
|
description: null,
|
|
groupCount: 1,
|
|
episodeCount: 25,
|
|
network: null,
|
|
},
|
|
],
|
|
error: null,
|
|
});
|
|
|
|
const structure: EpisodeStructureSeasons = {
|
|
groupId: "grp-production",
|
|
name: "制作季",
|
|
current: true,
|
|
seasons: [
|
|
{
|
|
seasonNumber: 1,
|
|
name: "第一部",
|
|
episodeCount: 12,
|
|
airDate: "2024-01-07",
|
|
posterPath: null,
|
|
inLibrary: 0,
|
|
seasonUnitId: "1b1f2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
|
|
episodes: [],
|
|
},
|
|
{
|
|
seasonNumber: 2,
|
|
name: "第二部",
|
|
episodeCount: 13,
|
|
airDate: "2024-07-07",
|
|
posterPath: null,
|
|
inLibrary: 1,
|
|
seasonUnitId: "2b1f2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
|
|
episodes: [
|
|
{
|
|
episodeNumber: 2,
|
|
name: "下篇第2话",
|
|
airDate: "2024-07-14",
|
|
tmdbSeason: 1,
|
|
tmdbEpisode: 14,
|
|
inLibrary: true,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
function withCache(el: ReactElement, list: EpisodeGroupList | null, seasons?: EpisodeStructureSeasons) {
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } } });
|
|
if (list) qc.setQueryData(episodeMappingKeys.groups(ENTITY), list);
|
|
if (seasons) qc.setQueryData(episodeMappingKeys.structure(ENTITY, seasons.groupId), seasons);
|
|
return renderToStaticMarkup(createElement(QueryClientProvider, { client: qc }, el));
|
|
}
|
|
|
|
describe("季集结构:纯函数", () => {
|
|
it("轨道选项:TMDB 默认季在前,剧集组带类型 · 季数 · 集数,当前选择打标", () => {
|
|
const opts = structureOptions(groups("grp-production"));
|
|
expect(opts.map((o) => [o.groupId, o.label, o.meta, o.current])).toEqual([
|
|
[null, "TMDB 默认季", null, false],
|
|
["grp-production", "制作季", "制作季 · 2 季 · 25 集", true],
|
|
["grp-aired", "播出顺序", "原始播出 · 1 季 · 25 集", false],
|
|
]);
|
|
// 当前剧集组已从 TMDB 删除:仍列出
|
|
const gone = structureOptions({ ...groups("old"), groups: [] });
|
|
expect(gone.at(-1)).toMatchObject({ groupId: "old", current: true, meta: "TMDB 上已不存在" });
|
|
expect(hasStructureChoice({ ...groups(null), groups: [] })).toBe(false);
|
|
expect(hasStructureChoice(groups(null))).toBe(true);
|
|
});
|
|
|
|
it("季卡片文案与原季集对照", () => {
|
|
expect(seasonLine({ airDate: "2024-07-07", episodeCount: 13 })).toBe("2024 · 13 集");
|
|
expect(inLibraryText({ inLibrary: 0, episodeCount: 13 })).toBeNull();
|
|
expect(inLibraryText({ inLibrary: 3, episodeCount: 13 })).toBe("已整理 3/13");
|
|
expect(originText(2, { episodeNumber: 2, tmdbSeason: 1, tmdbEpisode: 14 })).toBe("原 S01E14");
|
|
expect(originText(1, { episodeNumber: 2, tmdbSeason: 1, tmdbEpisode: 2 })).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("季集结构:组件", () => {
|
|
it("剧集组轨道:每项一张小卡,选中的 aria-pressed", () => {
|
|
const html = renderToStaticMarkup(
|
|
createElement(StructureTrack, {
|
|
options: structureOptions(groups(null)),
|
|
value: "grp-aired",
|
|
onChange: () => {},
|
|
}),
|
|
);
|
|
expect(html).toContain("TMDB 默认季");
|
|
expect(html).toContain("制作季 · 2 季 · 25 集");
|
|
expect(html).toMatch(/aria-pressed="true"[^>]*>.*播出顺序/);
|
|
expect(html).toContain('aria-label="当前"');
|
|
});
|
|
|
|
it("季卡片:季名、首播年 · 集数、已整理 x/y,选中的高亮", () => {
|
|
const html = renderToStaticMarkup(
|
|
createElement(SeasonCardGrid, { seasons: structure.seasons, selected: 2, onPick: () => {} }),
|
|
);
|
|
expect(html).toContain("第一部");
|
|
expect(html).toContain("2024 · 12 集");
|
|
expect(html).toContain("已整理 1/13");
|
|
expect(html).not.toContain("已整理 0/12");
|
|
expect(html).toMatch(/aria-pressed="true"[^>]*class="[^"]*pill-selected/);
|
|
});
|
|
|
|
it("订阅选季:有剧集组时显示轨道 + 全部季 + 季卡片,当前结构下按季单元回显选中", () => {
|
|
const html = withCache(
|
|
createElement(SubscriptionSeasonPicker, {
|
|
entityId: ENTITY,
|
|
value: "2b1f2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
|
|
onChange: () => {},
|
|
}),
|
|
groups("grp-production"),
|
|
structure,
|
|
);
|
|
expect(html).toContain("季集结构");
|
|
expect(html).toContain("全部季");
|
|
expect(html).toContain("第二部");
|
|
expect(html).toMatch(/aria-pressed="true"[^>]*class="[^"]*pill-selected[^"]*"[^>]*>.*?第二部/);
|
|
expect(html).not.toContain("不是作品当前的季集结构");
|
|
});
|
|
|
|
it("没有剧集组时:订阅选季不出现(表单用下拉),作品页只显示原来的季与集", () => {
|
|
const empty = { ...groups(null), groups: [] };
|
|
expect(
|
|
withCache(createElement(SubscriptionSeasonPicker, { entityId: ENTITY, value: "", onChange: () => {} }), empty),
|
|
).toBe("");
|
|
const html = withCache(
|
|
createElement(EpisodeStructureSwitcher, { entityId: ENTITY }, createElement("p", null, "原季列表")),
|
|
empty,
|
|
);
|
|
expect(html).toBe("<p>原季列表</p>");
|
|
});
|
|
|
|
it("作品页:有剧集组时轨道在季列表上方,当前结构显示原来的季与集", () => {
|
|
const html = withCache(
|
|
createElement(EpisodeStructureSwitcher, { entityId: ENTITY }, createElement("p", null, "原季列表")),
|
|
groups("grp-production"),
|
|
);
|
|
expect(html).toContain("季集结构");
|
|
expect(html).toContain("原季列表");
|
|
expect(html).not.toContain("改用这套结构");
|
|
});
|
|
});
|