feat(subtitles): 字幕站(SubHD、字幕库)在线搜索与下载;rar / 7z 解压;验证码与页面解析在沙箱里

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 02:28:29 +08:00
1 parent 92d456d123
commit 59b3680f35
32 files changed
+1704 -166

No files matched your search

+16
View File
@@ -28,6 +28,22 @@ export const subtitlesApi = {
body: subtitles.SubtitleApplyRequest,
response: subtitles.SubtitleApplyResult,
}),
search: ep({
method: "post",
path: "/subtitles/search",
scope: "read",
summary: "在已启用的字幕站(SubHD、字幕库)搜这部作品的字幕;按季集、语言、下载量排序",
body: subtitles.SubtitleSearchRequest,
response: subtitles.SubtitleSearchResponse,
}),
fetch: ep({
method: "post",
path: "/subtitles/fetch",
scope: "read",
summary: "下载一条在线字幕并解析(同上传:解压、转 UTF-8、语言与季集、配对建议;不写文件)",
body: subtitles.SubtitleFetchRequest,
response: subtitles.SubtitleParseResponse,
}),
writes: ep({
method: "get",
path: "/subtitles/writes",
+16 -2
View File
@@ -6,7 +6,7 @@ import { QuickFilterParam, ReleaseQuickFacets } from "./release-facets.ts";
// 含 passkey 的 Feed URL 只以 urlSecretRef 保存,对外只给脱敏的 urlDisplay。
/** api = MP 专用 API spider(Haidan/HDDolby/RousiPro/SunnyPT/TNode/TorrentLeech/Yema) */
export const SiteAdapter = z.enum(["nexusphp", "mteam_api", "api", "generic_rss"]);
export const SiteAdapter = z.enum(["nexusphp", "mteam_api", "api", "generic_rss", "subtitle"]);
export type SiteAdapter = z.infer<typeof SiteAdapter>;
/**
@@ -14,7 +14,7 @@ export type SiteAdapter = z.infer<typeof SiteAdapter>;
* 站点实际可用的方式 = 类型允许的 ∩ 适配器(站点定义)实现的,见 siteCapabilities。
* torznab 只定义结构(Jackett / Prowlarr 的 Torznab 接口),尚未实现:不能添加,也不参与调度。
*/
export const SiteKind = z.enum(["pt", "public_bt", "rss_feed", "torznab"]);
export const SiteKind = z.enum(["pt", "public_bt", "rss_feed", "torznab", "subtitle"]);
export type SiteKind = z.infer<typeof SiteKind>;
/** 获取方式:搜索 / 列表抓取 / RSS / 详情页 / 登录 / 账号数据(上传量、分享率、等级) */
export const SiteCapability = z.enum(["search", "list", "rss", "detail", "login", "userinfo"]);
@@ -62,6 +62,13 @@ export const SITE_KINDS: Record<SiteKind, SiteKindSpec> = {
implemented: false,
fromDefinition: false,
},
subtitle: {
label: "字幕站",
description: "给媒体库里的作品搜字幕、下载字幕:SubHD、字幕库",
capabilities: [],
implemented: true,
fromDefinition: false,
},
};
/** 站点定义(适配器)声明的能力;没有定义的站点传 null */
@@ -71,6 +78,13 @@ export type SiteDefinitionCaps = { search: boolean; list: boolean; detail: boole
* 站点实际可用的获取方式:类型允许的 ∩ 适配器实现的。RSS 走通用解析器,不依赖站点定义;
* 搜索 / 列表 / 详情要站点定义声明支持;登录与账号数据要有站点定义(没有定义就没有登录流程)。
*/
/** 内置字幕站(docs/plan/subtitles.md):站点页「字幕站」一键添加,搜索与下载在 subtitles 模块 */
export const SUBTITLE_SITES = [
{ key: "subhd", name: "SubHD", baseUrl: "https://subhd.tv/" },
{ key: "zimuku", name: "字幕库", baseUrl: "https://zmk.pw/" },
] as const;
export type SubtitleSiteKey = (typeof SUBTITLE_SITES)[number]["key"];
export function siteCapabilities(kind: SiteKind, def: SiteDefinitionCaps): SiteCapability[] {
const spec = SITE_KINDS[kind];
if (!spec.implemented) return [];
+55
View File
@@ -69,6 +69,9 @@ export const SubtitleParsedItem = z.object({
content: z.string(),
/** 建议的视频位置 */
targetLocationId: Id.nullable(),
/** 在线下载的:字幕站与条目地址(写入时记进历史) */
source: SubtitleSource.optional(),
sourceRef: z.string().optional(),
});
export type SubtitleParsedItem = z.infer<typeof SubtitleParsedItem>;
@@ -91,6 +94,8 @@ export const SubtitleApplyRequest = z.object({
targetLocationId: Id,
/** 在线来源的条目地址 */
sourceRef: z.string().max(1000).optional(),
/** 这一项的来源(不给用整批的 source) */
source: SubtitleSource.optional(),
}),
)
.min(1)
@@ -140,3 +145,53 @@ export const SubtitleWriteRow = z.object({
export const SubtitleWritesQuery = z.object({ entityId: Id });
export const SubtitleWritesResponse = z.object({ items: z.array(SubtitleWriteView) });
// ── 在线(字幕站:SubHD、字幕库) ──
export const SubtitleSearchRequest = z.object({
entityId: Id,
/** 剧集:只要这一季(集)的字幕;不给 = 全部 */
season: z.number().int().min(0).optional(),
episode: z.number().int().min(0).optional(),
/** 额外的搜索词(作品名搜不到时手动填) */
keyword: z.string().trim().max(100).optional(),
});
export type SubtitleSearchRequest = z.infer<typeof SubtitleSearchRequest>;
export const SubtitleSearchResult = z.object({
siteId: Id,
siteKey: z.string(),
siteName: z.string(),
/** 站内引用(下载时原样提交) */
ref: z.string(),
title: z.string(),
pageUrl: z.string(),
languages: z.array(z.string()),
format: z.string().nullable(),
downloads: z.number().int().nullable(),
uploader: z.string().nullable(),
publishedAt: z.string().nullable(),
season: z.number().int().nullable(),
episode: z.number().int().nullable(),
score: z.number(),
});
export type SubtitleSearchResult = z.infer<typeof SubtitleSearchResult>;
export const SubtitleSearchResponse = z.object({
results: z.array(SubtitleSearchResult),
/** 用到的搜索词 */
keywords: z.array(z.string()),
/** 各站出错 / 需要人工处理 */
errors: z.array(z.object({ siteName: z.string(), reason: z.string() })),
/** 没有启用的字幕站 */
noSites: z.boolean(),
});
export type SubtitleSearchResponse = z.infer<typeof SubtitleSearchResponse>;
export const SubtitleFetchRequest = z.object({
entityId: Id,
siteId: Id,
ref: z.string().min(1).max(500),
title: z.string().max(500).default(""),
});
export type SubtitleFetchRequest = z.input<typeof SubtitleFetchRequest>;