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[];