diff --git a/apps/server/src/modules/subtitles/online/index.ts b/apps/server/src/modules/subtitles/online/index.ts index 5c5801a0..a3c3d132 100644 --- a/apps/server/src/modules/subtitles/online/index.ts +++ b/apps/server/src/modules/subtitles/online/index.ts @@ -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 { + 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 { 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/、字幕库 /detail/.html);不是详情页为 null */ diff --git a/apps/server/src/modules/subtitles/online/parse.ts b/apps/server/src/modules/subtitles/online/parse.ts index 08d0ffc9..c89f118e 100644 --- a/apps/server/src/modules/subtitles/online/parse.ts +++ b/apps/server/src/modules/subtitles/online/parse.ts @@ -4,6 +4,8 @@ export interface PageEntry { /** 站内引用:SubHD 为 /a/,字幕库为 /detail/.html */ ref: string; + /** API 来源可给稳定详情页,不能放签名下载地址。 */ + pageUrl?: string; title: string; /** 简体 / 繁体 / 双语 / 英文 …(站点标注或从标题、说明里猜) */ languages: string[]; diff --git a/apps/server/test/subtitles/mteam-subtitles.test.ts b/apps/server/test/subtitles/mteam-subtitles.test.ts new file mode 100644 index 00000000..1152b9e7 --- /dev/null +++ b/apps/server/test/subtitles/mteam-subtitles.test.ts @@ -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; + 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(/已停用/); + }); +}); diff --git a/packages/contracts/src/subtitles.ts b/packages/contracts/src/subtitles.ts index 2c13e60b..7a19c18c 100644 --- a/packages/contracts/src/subtitles.ts +++ b/packages/contracts/src/subtitles.ts @@ -17,7 +17,7 @@ export const SubtitleLanguage = z.enum([ ]); export type SubtitleLanguage = z.infer; export const SubtitleExt = z.enum(["srt", "ass", "ssa", "vtt", "sub", "sbv"]); -export const SubtitleSource = z.enum(["upload", "subhd", "zimuku"]); +export const SubtitleSource = z.enum(["upload", "subhd", "zimuku", "mteam"]); export type SubtitleSource = z.infer; /** 可写字幕的视频(作品在媒体库里的视频位置) */ @@ -146,7 +146,7 @@ export const SubtitleWriteRow = z.object({ export const SubtitleWritesQuery = z.object({ entityId: Id }); export const SubtitleWritesResponse = z.object({ items: z.array(SubtitleWriteView) }); -// ── 在线(字幕站:SubHD、字幕库) ── +// ── 在线(字幕站:SubHD、字幕库;已启用的 M-Team 站点) ── export const SubtitleSearchRequest = z.object({ entityId: Id, diff --git a/packages/db/migrations/0096_mteam_subtitles.ts b/packages/db/migrations/0096_mteam_subtitles.ts new file mode 100644 index 00000000..19491639 --- /dev/null +++ b/packages/db/migrations/0096_mteam_subtitles.ts @@ -0,0 +1,22 @@ +import type { Kysely } from "kysely"; +import { execDdl, oneOf } from "../src/migration-helpers.ts"; + +// M-Team 字幕沿用字幕资产与可撤销历史,只扩展来源,不保存下载签名。 +// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型 +export async function up(db: Kysely): Promise { + await execDdl( + db, + `alter table subtitle_write drop constraint subtitle_write_source_check; + alter table subtitle_write add constraint subtitle_write_source_check ${oneOf("source", ["upload", "subhd", "zimuku", "mteam"])};`, + ); +} + +// biome-ignore lint/suspicious/noExplicitAny: 同上 +export async function down(db: Kysely): Promise { + // 有新来源的历史时拒绝回退,不删除记录或改写其来源。 + await execDdl( + db, + `alter table subtitle_write drop constraint subtitle_write_source_check; + alter table subtitle_write add constraint subtitle_write_source_check ${oneOf("source", ["upload", "subhd", "zimuku"])};`, + ); +} diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 5105e31f..41187572 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -267,7 +267,7 @@ export type SubscriptionSeasonScope = "current_season" | "following_seasons"; export type SubscriptionOrigin = "user" | "agent" | "policy" | "import"; export type SubscriptionPriority = "immediate" | "tracking" | "new_work" | "upgrade"; export type SubscriptionStatus = "active" | "paused" | "pending" | "completed" | "cancelled"; -export type SubtitleWriteSource = "upload" | "subhd" | "zimuku"; +export type SubtitleWriteSource = "upload" | "subhd" | "zimuku" | "mteam"; export type TaskStatus = "pending" | "running" | "waiting" | "succeeded" | "failed" | "cancelled" | "dead"; export type TasksOpLedgerStatus = "started" | "succeeded"; export type TitleAliasKind = "original" | "translated" | "short" | "site_title" | "romanized" | "other";