Adapters (pure functions + tests): - detail-description: strip HTML/BBCode residue, drop site template blocks and ad/signature lines (configurable rules), parse PT info blocks (◎ / 【】 / 『』 / 「键:」) into infoBlock and body paragraphs - mediainfo: MediaInfo and BDInfo (quick summary + full report) into a summary of container/duration/bitrate/size, video (HDR/DV), audio and subtitle tracks - file-tree: file list into a flattened directory tree with sizes and main/sample/subtitle/extra roles - formatDetailPage combines them; the server computes it when reading the cached detail page, so existing caches benefit without a refetch Contracts: SiteReleasePage gains infoBlock, paragraphs, media, fileTree. Web: drawer now reads top-down: meta row (promo/H&R, size, peers, upload time, IMDb/TMDB/豆瓣 tags) → 资料 → 简介 (collapsible) → 技术规格 (raw folded) → 截图 grid with keyboard lightbox → 文件 tree; inventory overlay moved into the fixed footer next to 下载. Fixtures: 6 desensitized samples from the local cache (site.detail and RSS descriptions) plus synthetic multi-track MediaInfo, BDInfo, bracket blocks and directory file lists. Co-Authored-By: Claude Opus 5.5 <[email protected]>
161 lines
6.8 KiB
TypeScript
161 lines
6.8 KiB
TypeScript
import {
|
|
AUDIENCES_FIXTURE_RELEASES,
|
|
createMockNexusPhpSite,
|
|
createMockTmdb,
|
|
type MockNexusPhpSite,
|
|
type MockTmdb,
|
|
} from "@umt/testkit";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { DETAIL_PAGE_REGION } from "../../src/modules/sources-identity/service/detail-page.ts";
|
|
import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
|
|
import { snapshotSite } from "../../src/modules/sources-identity/service/snapshot.ts";
|
|
import { sharedCache } from "../../src/shared-cache.ts";
|
|
import { createHarness, type Harness, json, pgAvailable, seedSite } from "./harness.ts";
|
|
|
|
const up = await pgAvailable();
|
|
|
|
const SHOT = "http://93.184.216.34/shots/1.png";
|
|
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]);
|
|
|
|
describe.skipIf(!up)("站点详情页扩展内容:按需抓 + 24 小时缓存 + 截图代理 + 外部 ID 进身份解析", () => {
|
|
let h: Harness;
|
|
let mock: MockNexusPhpSite;
|
|
let tmdb: MockTmdb;
|
|
let siteId: string;
|
|
let relId: string;
|
|
const imageFetches: string[] = [];
|
|
|
|
beforeAll(async () => {
|
|
const releases = AUDIENCES_FIXTURE_RELEASES.map((r) =>
|
|
r.id === "812345"
|
|
? {
|
|
...r,
|
|
descriptionHtml: `◎片名 沙丘2<br>◎IMDb <a href="https://www.imdb.com/title/tt15239678/">tt15239678</a><br>
|
|
<img src="${SHOT}"><img src="pic/smilies/2.gif">
|
|
<div class="codemain">General<br>Complete name : Dune.mkv<br>Format : Matroska<br>File size : 78 GiB<br>Duration : 2 h 46 min<br>Video<br>Format : HEVC</div>`,
|
|
}
|
|
: r,
|
|
);
|
|
mock = await createMockNexusPhpSite({ releases });
|
|
tmdb = await createMockTmdb();
|
|
h = await createHarness("detailpage", {
|
|
tmdbUrl: tmdb.url,
|
|
fetch: (input, init) => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
if (url.startsWith("http://93.184.216.34/")) {
|
|
imageFetches.push(url);
|
|
return Promise.resolve(new Response(PNG, { status: 200, headers: { "content-type": "image/png" } }));
|
|
}
|
|
return fetch(input, init);
|
|
},
|
|
});
|
|
siteId = (await seedSite(h, { baseUrl: `${mock.baseUrl}/`, cookie: mock.cookie })).site.id;
|
|
await snapshotSite(h.rt, { siteId, kind: "manual" });
|
|
relId = (
|
|
await h.db
|
|
.selectFrom("source_release")
|
|
.select(["id"])
|
|
.where("site_release_id", "=", "812345")
|
|
.executeTakeFirstOrThrow()
|
|
).id;
|
|
});
|
|
afterAll(async () => {
|
|
await h?.close();
|
|
await mock?.close();
|
|
await tmdb?.close();
|
|
});
|
|
|
|
const detailRequests = () => mock.requests.filter((r) => r.path.startsWith("/details.php")).length;
|
|
|
|
it("未抓过:page 为 null,标明站点支持详情页;不刷新就不请求站点", async () => {
|
|
const before = detailRequests();
|
|
const d = await json(await h.req(`/sites/${siteId}/releases/${relId}`));
|
|
expect(d.status).toBe(200);
|
|
expect(d.body.data).toMatchObject({ page: null, detailSupported: true });
|
|
expect(detailRequests()).toBe(before);
|
|
});
|
|
|
|
it("refresh 抓一次:简介 / MediaInfo / 截图(代理路径)/ IMDb;外部 ID 写 raw._detail;缓存期内不再请求", async () => {
|
|
const before = detailRequests();
|
|
const d = await json(await h.req(`/sites/${siteId}/releases/${relId}?refresh=true`));
|
|
expect(d.body.data.refreshError).toBeNull();
|
|
expect(detailRequests()).toBe(before + 1);
|
|
const page = d.body.data.page;
|
|
expect(page).toMatchObject({
|
|
imdbId: "tt15239678",
|
|
screenshots: [{ url: `/api/v1/img/site-detail/${relId}/0`, host: "93.184.216.34" }],
|
|
});
|
|
expect(page.mediaInfo).toMatch(/^General\nComplete name : Dune.mkv/);
|
|
expect(page.descriptionText).toContain("◎片名 沙丘2");
|
|
expect(page.descriptionText).not.toContain("Complete name");
|
|
// 展示结构:读缓存时现算
|
|
expect(page.infoBlock).toContainEqual({ key: "片名", value: "沙丘2" });
|
|
expect(page.media).toMatchObject({
|
|
kind: "mediainfo",
|
|
general: { fileName: "Dune.mkv", container: "Matroska", size: "78 GiB", duration: "2 h 46 min" },
|
|
video: [{ codec: "HEVC" }],
|
|
});
|
|
expect(Array.isArray(page.fileTree)).toBe(true);
|
|
const row = await h.db
|
|
.selectFrom("source_release")
|
|
.select(["raw"])
|
|
.where("id", "=", relId)
|
|
.executeTakeFirstOrThrow();
|
|
expect((row.raw as { _detail?: unknown })._detail).toEqual({ imdbId: "tt15239678" });
|
|
|
|
const again = await json(await h.req(`/sites/${siteId}/releases/${relId}?refresh=true`));
|
|
expect(again.body.data.page.imdbId).toBe("tt15239678");
|
|
expect(detailRequests()).toBe(before + 1);
|
|
|
|
// 列表快照覆盖 raw._site 后,详情给出的外部 ID 仍在
|
|
await snapshotSite(h.rt, { siteId, kind: "manual" });
|
|
const row2 = await h.db
|
|
.selectFrom("source_release")
|
|
.select(["raw"])
|
|
.where("id", "=", relId)
|
|
.executeTakeFirstOrThrow();
|
|
expect((row2.raw as { _detail?: unknown })._detail).toEqual({ imdbId: "tt15239678" });
|
|
});
|
|
|
|
it("截图代理:只取缓存里的第 index 张,按文件头认图片;越界 404", async () => {
|
|
const res = await h.req(`/img/site-detail/${relId}/0`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("content-type")).toBe("image/png");
|
|
expect(new Uint8Array(await res.arrayBuffer())).toEqual(PNG);
|
|
expect(imageFetches).toEqual([SHOT]);
|
|
expect((await h.req(`/img/site-detail/${relId}/5`)).status).toBe(404);
|
|
expect((await h.req(`/img/site-detail/not-an-id/0`)).status).toBe(400);
|
|
});
|
|
|
|
it("缓存丢了(kv_cache 过期)再刷新会重新抓一次", async () => {
|
|
await sharedCache(h.ctx).delete(DETAIL_PAGE_REGION, relId);
|
|
const before = detailRequests();
|
|
const d = await json(await h.req(`/sites/${siteId}/releases/${relId}`));
|
|
expect(d.body.data.page).toBeNull();
|
|
await h.req(`/sites/${siteId}/releases/${relId}?refresh=true`);
|
|
expect(detailRequests()).toBe(before + 1);
|
|
});
|
|
|
|
it("身份解析:详情页的 IMDb ID 走 TMDB find,不按标题搜索,显式 ID 证据", async () => {
|
|
const searches = () => tmdb.callsTo(/\/3\/search\//).length;
|
|
const s0 = searches();
|
|
const res = await resolveRelease(h.rt, { sourceReleaseId: relId, force: true, trigger: "manual" });
|
|
expect(tmdb.callsTo(/\/3\/find\/tt15239678/).length).toBeGreaterThan(0);
|
|
expect(searches()).toBe(s0);
|
|
expect(res.outcome).toBe("auto");
|
|
const ev = await h.db
|
|
.selectFrom("source_release")
|
|
.select(["resolution_evidence", "media_entity_id"])
|
|
.where("id", "=", relId)
|
|
.executeTakeFirstOrThrow();
|
|
expect(ev.media_entity_id).toBe(res.mediaEntityId);
|
|
const ext = await h.db
|
|
.selectFrom("external_identity")
|
|
.select(["provider", "external_id"])
|
|
.where("media_entity_id", "=", res.mediaEntityId!)
|
|
.where("provider", "=", "tmdb")
|
|
.executeTakeFirstOrThrow();
|
|
expect(ext.external_id).toBe("693134");
|
|
});
|
|
});
|