import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { createApp } from "../../src/http/app.ts"; import { sourcesIdentityModule } from "../../src/modules/sources-identity/index.ts"; import { createSubtitlesModule } from "../../src/modules/subtitles/index.ts"; import { downloadOnline } from "../../src/modules/subtitles/online/index.ts"; import { DEV_KEY } from "../helpers.ts"; import { createHarness, type Harness, pgAvailable } from "../sources-identity/harness.ts"; const hasPg = await pgAvailable(); const subtitle = "1\n00:00:01,000 --> 00:00:03,000\n你好,字幕测试\n\n"; describe.skipIf(!hasPg)("M-Team在线字幕:真实API形状与写入历史", () => { let h: Harness; let app: ReturnType; let lib: string; let entityId: string; let locationId: string; let siteId: string; let accountId: string; let issued = 0; let failSearch = false; const calls: { path: string; url: string; headers: Headers; body: string | null }[] = []; const api = async (path: string, body?: unknown) => { const res = await app.request(`/api/v1${path}`, { method: body === undefined ? "GET" : "POST", headers: { authorization: `Bearer ${DEV_KEY}`, "content-type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); return { status: res.status, body: await res.json() }; }; beforeAll(async () => { lib = await mkdtemp(join(tmpdir(), "umt-mteam-subs-")); h = await createHarness("mteam_subtitles", { fetch: async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); const headers = new Headers(init?.headers); calls.push({ path: url.pathname, url: url.href, headers, body: typeof init?.body === "string" ? init.body : null, }); expect(url.origin).toBe("https://mteam-api.example"); if (url.pathname === "/api/subtitle/search") { expect(headers.get("x-api-key")).toBe("private-mteam-api-key"); expect(headers.get("user-agent")).toBe("SubtitleRegression/1.0"); if (failSearch) return new Response("字幕服务维护", { status: 503 }); return Response.json({ code: "0", message: "SUCCESS", data: { totalPages: 1, data: [ { id: "sub-100", name: "示例剧.S01E02.简体", filename: "Example.Show.S01E02.chs.srt", lang: "zh-Hans", torrent: { id: "987" }, hits: 23, author: "translator", createdDate: "2026-11-01T12:00:00+08:00", }, { id: "sub-101", name: "示例剧.S01E02.双语", filename: "Example.Show.S01E02.chs&eng.ass", language: "chs&eng", hits: 5, }, { id: "sub-102", name: "示例剧.S02E02.简体", filename: "Example.Show.S02E02.srt" }, { id: "sub-103", name: "另一部剧.S01E02", filename: "Other.Show.srt" }, ], }, }); } if (url.pathname === "/api/subtitle/genlink") { expect(headers.get("x-api-key")).toBe("private-mteam-api-key"); expect(new URLSearchParams(String(init?.body)).get("id")).toBe("sub-100"); issued++; return Response.json({ code: "0", message: "SUCCESS", data: `private-credential-${issued}` }); } if (url.pathname === "/api/subtitle/dlV2") { expect(url.searchParams.get("credential")).toBe(`private-credential-${issued}`); return new Response(subtitle, { headers: { "content-type": "application/octet-stream", "content-disposition": 'attachment; filename="Example.Show.S01E02.chs.srt"', }, }); } throw new Error(`Unexpected path ${url.pathname}`); }, }); h.ctx.config.DATA_DIR = join(lib, ".data"); vi.spyOn(sourcesIdentityModule, "runtime").mockReturnValue(h.rt); app = createApp({ config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY }, logger: h.ctx.logger, bus: h.ctx.events, role: "all", version: "0.0.0-test", modules: [createSubtitlesModule()], ctx: h.ctx, }); const root = await h.db .insertInto("library_root") .values({ name: "字幕测试库", media_kind: "mixed", local_path: lib, management_mode: "managed" }) .returning("id") .executeTakeFirstOrThrow(); entityId = ( await h.db .insertInto("media_entity") .values({ kind: "series", title: "示例剧", year: 2024 }) .returning("id") .executeTakeFirstOrThrow() ).id; const asset = await h.db .insertInto("media_asset") .values({ media_entity_id: entityId, kind: "video", status: "active" }) .returning("id") .executeTakeFirstOrThrow(); await writeFile(join(lib, "Example.Show.S01E02.mkv"), "video"); locationId = ( await h.db .insertInto("asset_location") .values({ media_asset_id: asset.id, location_kind: "library", library_root_id: root.id, relative_path: "Example.Show.S01E02.mkv", status: "available", }) .returning("id") .executeTakeFirstOrThrow() ).id; const unit = await h.db .insertInto("content_unit") .values({ media_entity_id: entityId, kind: "episode", season_number: 1, episode_number: 2 }) .returning("id") .executeTakeFirstOrThrow(); await h.db .insertInto("asset_unit_coverage") .values({ media_asset_id: asset.id, content_unit_id: unit.id }) .execute(); siteId = ( await h.db .insertInto("site") .values({ key: "mteam", name: "馒头", kind: "pt", adapter: "mteam_api", base_url: "https://kp.m-team.cc/", rate_limit: JSON.stringify({ minIntervalMs: 0, burst: 1 }), config: JSON.stringify({ apiBaseUrl: "https://mteam-api.example" }), }) .returning("id") .executeTakeFirstOrThrow() ).id; const secret = await h.secrets.create({ purpose: "site_api_key", plaintext: "private-mteam-api-key" }); accountId = ( await h.db .insertInto("site_account") .values({ site_id: siteId, label: "main", auth_kind: "api_key", secret_ref: secret, user_agent: "SubtitleRegression/1.0", }) .returning("id") .executeTakeFirstOrThrow() ).id; }, 120_000); afterAll(async () => { vi.restoreAllMocks(); await h?.close(); if (lib) await rm(lib, { recursive: true, force: true }); }); it("已启用PT站纳入搜索,统一凭据和UA,只返回稳定ID和详情页", async () => { const result = await api("/subtitles/search", { entityId, season: 1, episode: 2 }); expect(result.status, JSON.stringify(result.body)).toBe(200); expect(result.body.data.errors).toEqual([]); expect(result.body.data.noSites).toBe(false); expect(result.body.data.results).toHaveLength(2); expect(result.body.data.results.find((r: { ref: string }) => r.ref === "sub-100")).toMatchObject({ siteId, siteKey: "mteam", siteName: "馒头", ref: "sub-100", pageUrl: "https://kp.m-team.cc/detail/987", languages: ["简体"], format: "SRT", downloads: 23, season: 1, episode: 2, }); expect(result.body.data.results.find((r: { ref: string }) => r.ref === "sub-101").pageUrl).toBe( "https://kp.m-team.cc/", ); expect(JSON.parse(calls.find((c) => c.path === "/api/subtitle/search")!.body!)).toEqual({ keyword: "示例剧", pageNumber: 1, pageSize: 20, }); expect(calls.some((c) => c.path === "/api/subtitle/genlink")).toBe(false); expect(JSON.stringify(result.body)).not.toMatch(/private-|credential|x-api-key/); }); it("下载时现场换签名,沙箱解析后写入与撤销,历史只记稳定字幕ID", async () => { const result = await api("/subtitles/fetch", { entityId, siteId, ref: "sub-100" }); expect(result.status).toBe(200); const [item] = result.body.data.items; expect(item).toMatchObject({ source: "mteam", sourceRef: "mteam:subtitle:sub-100", ext: "srt", targetLocationId: locationId, }); expect(JSON.stringify(result.body)).not.toContain("private-"); const written = await api("/subtitles/apply", { entityId, items: [item] }); expect(written.status, JSON.stringify(written.body)).toBe(200); expect(written.body.data.failed).toEqual([]); const [write] = written.body.data.written; expect(write.source).toBe("mteam"); expect(await readFile(join(lib, write.relativePath), "utf8")).toContain("你好"); const history = await h.db .selectFrom("subtitle_write") .select(["source", "source_ref"]) .where("id", "=", write.id) .executeTakeFirstOrThrow(); expect(history).toEqual({ source: "mteam", source_ref: "mteam:subtitle:sub-100" }); const fetchedAgain = await downloadOnline(h.ctx, h.rt, siteId, "sub-100"); expect(fetchedAgain.pageUrl).toBe("mteam:subtitle:sub-100"); expect(issued).toBe(2); const writes = await api(`/subtitles/writes?entityId=${entityId}`); expect(writes.body.data.items[0].source).toBe("mteam"); const undone = await api(`/subtitles/writes/${write.id}/undo`, {}); expect(undone.status).toBe(200); expect(undone.body.data.undoneAt).toBeTruthy(); await expect(readFile(join(lib, write.relativePath))).rejects.toThrow(); }); it("拒绝把下载签名或URL作为字幕引用,无需发请求", async () => { const before = calls.length; await expect( downloadOnline(h.ctx, h.rt, siteId, "https://mteam-api.example/api/subtitle/dlV2?credential=private-signature"), ).rejects.toThrow(/字幕 ID 非法/); expect(calls).toHaveLength(before); }); it("搜索接口失败保留状态和标题", async () => { failSearch = true; const result = await api("/subtitles/search", { entityId }); expect(result.status).toBe(200); expect(result.body.data.errors[0]).toMatchObject({ siteName: "馒头" }); expect(result.body.data.errors[0].reason).toContain("HTTP 503"); expect(result.body.data.errors[0].reason).toContain("字幕服务维护"); failSearch = false; }); it("账号失效和站点停用给出明确结果,不继续使用缓存凭据", async () => { await h.db.updateTable("site_account").set({ enabled: false }).where("id", "=", accountId).execute(); const before = calls.length; const result = await api("/subtitles/search", { entityId }); expect(result.body.data.errors[0].reason).toMatch(/API Key|凭据|密钥/); expect(calls).toHaveLength(before); await h.db.updateTable("site").set({ enabled: false }).where("id", "=", siteId).execute(); expect((await api("/subtitles/search", { entityId })).body.data.noSites).toBe(true); await expect(downloadOnline(h.ctx, h.rt, siteId, "sub-100")).rejects.toThrow(/已停用/); }); });