通过既有provider表复用M-Team站点适配器的凭据、UA和共享限流;搜索只返回稳定字幕ID,下载签名现场获取,沿用沙箱解析、写入历史与撤销。迁移0096扩展字幕来源,schema反射同步。独立测试库验证M-Team真实API形状与旧字幕来源。参考:MoviePilot v3.2.4 app/modules/indexer/spider/mtorrent.py。
280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
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<typeof createApp>;
|
|
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("<title>字幕服务维护</title>", { 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(/已停用/);
|
|
});
|
|
});
|