feat(subtitles): connect M-Team search and download to online workflow
通过既有provider表复用M-Team站点适配器的凭据、UA和共享限流;搜索只返回稳定字幕ID,下载签名现场获取,沿用沙箱解析、写入历史与撤销。迁移0096扩展字幕来源,schema反射同步。独立测试库验证M-Team真实API形状与旧字幕来源。参考:MoviePilot v3.2.4 app/modules/indexer/spider/mtorrent.py。
This commit is contained in:
1 parent
7e4cdbf5ba
commit
173f2e24c9
6 files changed
+369
-8
No files matched your search
@@ -0,0 +1,279 @@
|
||||
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(/已停用/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user