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

@@ -1,6 +1,7 @@
// 在线字幕(docs/plan/subtitles.md 第 2 步):在已启用的字幕站(site.kind = subtitle)搜索、下载。
// 在线字幕:在已启用的字幕站及 M-Team PT 站点搜索、下载。
// 请求走站点出口 rt.siteFetch(站点代理、UA、Cloudflare 挑战走浏览器);页面解析与验证码识别在沙箱里(ADR-005)。
// 流程对照 MP 插件 online_subtitles/providers/{subhd,zimuku}.py,页面结构 2026-10-03 实测。
import { SiteError } from "@umt/adapters";
import type { SubtitleSearchResponse, SubtitleSearchResult } from "@umt/contracts";
import type { SiteRow } from "@umt/db";
import { subtitleEpisode } from "@umt/domain";
@@ -11,7 +12,7 @@ import type { Actor } from "../../browser/service.ts";
import type { SourcesRuntime } from "../../sources-identity/runtime.ts";
import { singleIdentityIds } from "../../sources-identity/service/identity-ids.ts";
import type { ServerContext } from "../../types.ts";
import type { PageEntry, PageKind, PageResult } from "./parse.ts";
import { guessLanguages, type PageEntry, type PageKind, type PageResult } from "./parse.ts";
const MAX_DOWNLOAD_BYTES = 30 * 1024 * 1024;
@@ -142,7 +143,7 @@ function toResult(env: Env, e: PageEntry, q: SearchQuery): SubtitleSearchResult
siteName: env.site.name,
ref: e.ref,
title: e.title,
pageUrl: abs(env, e.ref),
pageUrl: e.pageUrl ?? abs(env, e.ref),
languages: e.languages,
format: e.format,
downloads: e.downloads,
@@ -288,6 +289,57 @@ async function zimukuDownload(env: Env, ref: string): Promise<{ name: string; by
throw new SubtitleSiteError(last);
}
// ── M-Team:与种子搜索共用账号、API 地址和站点限流,搜索时不生成下载签名 ──
function mteamSubtitleId(value: string): string {
if (!/^[A-Za-z0-9_-]{1,128}$/.test(value)) throw new SubtitleSiteError("M-Team 字幕 ID 非法,重新搜索后重试");
return value;
}
async function mteamSearch(env: Env, q: SearchQuery): Promise<PageEntry[]> {
const adapter = env.rt.siteAdapter(env.site);
if (!adapter.searchSubtitles) throw new SubtitleSiteError("M-Team 字幕搜索暂不可用");
for (const keyword of q.keywords.slice(0, 3)) {
const entries: PageEntry[] = [];
for (let pageNumber = 1; pageNumber <= 3; pageNumber++) {
const found = await adapter.searchSubtitles(keyword, { page: pageNumber });
for (const item of found.items) {
if (!relevant(item.title, q.keywords) || !episodeOk(item.title, q).ok) continue;
const ref = mteamSubtitleId(item.id);
const language = item.language?.replace(/^zh[-_]Hans$/i, "简体").replace(/^zh[-_]Hant$/i, "繁体") ?? "";
entries.push({
ref,
title: item.title,
pageUrl: item.torrentId ? abs(env, `/detail/${encodeURIComponent(item.torrentId)}`) : env.site.base_url,
languages: guessLanguages(`${language} ${item.title} ${item.fileName ?? ""}`),
format: /\.(srt|ass|ssa|vtt|sub|sbv)(?:$|\b)/i.exec(item.fileName ?? item.title)?.[1]?.toUpperCase() ?? null,
downloads: item.downloads,
uploader: item.uploader,
publishedAt: item.uploadedAt,
});
}
if (!found.items.length || (found.totalPages !== null && pageNumber >= found.totalPages)) break;
if (found.totalPages === null && found.items.length < 20) break;
}
if (entries.length) return entries;
}
return [];
}
async function mteamDownload(env: Env, ref: string): Promise<{ name: string; bytes: Uint8Array }> {
const id = mteamSubtitleId(ref);
const adapter = env.rt.siteAdapter(env.site);
if (!adapter.downloadSubtitle) throw new SubtitleSiteError("M-Team 字幕下载暂不可用");
try {
const file = await adapter.downloadSubtitle(id);
if (file.bytes.byteLength > MAX_DOWNLOAD_BYTES) throw new SubtitleSiteError("字幕包超过 30 MB");
return { name: file.fileName ?? `mteam-${id}.zip`, bytes: file.bytes };
} catch (error) {
if (error instanceof SiteError) throw new SubtitleSiteError(error.message);
throw error;
}
}
const PROVIDERS: Record<
string,
{
@@ -297,6 +349,7 @@ const PROVIDERS: Record<
> = {
subhd: { search: subhdSearch, download: subhdDownload },
zimuku: { search: zimukuSearch, download: zimukuDownload },
mteam: { search: mteamSearch, download: mteamDownload },
};
// ── 对外 ──
@@ -341,7 +394,7 @@ async function subtitleSites(ctx: ServerContext): Promise<SiteRow[]> {
return await ctx.db
.selectFrom("site")
.selectAll()
.where("kind", "=", "subtitle")
.where((eb) => eb.or([eb("kind", "=", "subtitle"), eb.and([eb("kind", "=", "pt"), eb("key", "=", "mteam")])]))
.where("enabled", "=", true)
.where("deleted_at", "is", null)
.orderBy("name")
@@ -393,7 +446,12 @@ export async function downloadOnline(
for (const c of cookies) jar.set(c.name, c.value);
}
const f = await PROVIDERS[site.key]!.download(env, ref);
return { name: f.name, base64: b64encode(f.bytes), siteKey: site.key, pageUrl: abs(env, ref) };
return {
name: f.name,
base64: b64encode(f.bytes),
siteKey: site.key,
pageUrl: site.key === "mteam" ? `mteam:subtitle:${mteamSubtitleId(ref)}` : abs(env, ref),
};
}
/** 字幕详情页地址 → 条目 ref(SubHD /a/<sid>、字幕库 /detail/<id>.html);不是详情页为 null */
@@ -4,6 +4,8 @@
export interface PageEntry {
/** 站内引用:SubHD 为 /a/<sid>,字幕库为 /detail/<id>.html */
ref: string;
/** API 来源可给稳定详情页,不能放签名下载地址。 */
pageUrl?: string;
title: string;
/** 简体 / 繁体 / 双语 / 英文 …(站点标注或从标题、说明里猜) */
languages: string[];
@@ -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(/已停用/);
});
});