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:
root committed 2026-11-06 00:09:56 +08:00
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(/已停用/);
});
});