feat(subtitles): 字幕站(SubHD、字幕库)在线搜索与下载;rar / 7z 解压;验证码与页面解析在沙箱里
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
92d456d123
commit
59b3680f35
32 files changed
+1704
-166
No files matched your search
@@ -25,6 +25,7 @@ import {
|
||||
SiteUserDataPoint,
|
||||
SiteUserDataQuery,
|
||||
type SourceRelease,
|
||||
SUBTITLE_SITES,
|
||||
} from "@umt/contracts";
|
||||
import { type FeedSourceRow, sql } from "@umt/db";
|
||||
import { z } from "zod";
|
||||
@@ -92,19 +93,35 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
const rows = await db().selectFrom("site").select(["id", "key"]).where("deleted_at", "is", null).execute();
|
||||
const byKey = new Map(rows.map((r) => [r.key, r.id]));
|
||||
return {
|
||||
items: listSiteDefinitions().map((d) => ({
|
||||
key: d.id,
|
||||
name: d.umt.kind === "nexusphp" ? (d.umt.displayName ?? d.name) : d.umt.displayName,
|
||||
kind: definitionKind(d),
|
||||
adapter: adapterOf(d),
|
||||
baseUrl: d.domain,
|
||||
shippedEnabled: d.enabled !== false,
|
||||
definitionVersion: d.umt.version,
|
||||
capabilities: d.umt.capabilities,
|
||||
defaultRateLimit: d.umt.rateLimit,
|
||||
authSupported: ("authSupported" in d.umt ? d.umt.authSupported : undefined) ?? [],
|
||||
siteId: byKey.get(d.id) ?? null,
|
||||
})),
|
||||
items: [
|
||||
...listSiteDefinitions().map((d) => ({
|
||||
key: d.id,
|
||||
name: d.umt.kind === "nexusphp" ? (d.umt.displayName ?? d.name) : d.umt.displayName,
|
||||
kind: definitionKind(d),
|
||||
adapter: adapterOf(d),
|
||||
baseUrl: d.domain,
|
||||
shippedEnabled: d.enabled !== false,
|
||||
definitionVersion: d.umt.version,
|
||||
capabilities: d.umt.capabilities,
|
||||
defaultRateLimit: d.umt.rateLimit,
|
||||
authSupported: ("authSupported" in d.umt ? d.umt.authSupported : undefined) ?? [],
|
||||
siteId: byKey.get(d.id) ?? null,
|
||||
})),
|
||||
// 内置字幕站:不是资源适配器,只用于补字幕(subtitles 模块)
|
||||
...SUBTITLE_SITES.map((d) => ({
|
||||
key: d.key,
|
||||
name: d.name,
|
||||
kind: "subtitle" as const,
|
||||
adapter: "subtitle" as const,
|
||||
baseUrl: d.baseUrl,
|
||||
shippedEnabled: true,
|
||||
definitionVersion: "builtin",
|
||||
capabilities: { list: false, search: false, detail: false, download: true, rss: false },
|
||||
defaultRateLimit: { minIntervalMs: 2000, burst: 2 },
|
||||
authSupported: [],
|
||||
siteId: byKey.get(d.key) ?? null,
|
||||
})),
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -159,6 +176,9 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
throw new ApiException("bad_request", `注册表中没有站点 ${body.key};未适配站点只能以 generic_rss 登记`);
|
||||
}
|
||||
if (def && definitionKind(def) !== "pt") throw new ApiException("bad_request", `${body.key} 不是 PT 站`);
|
||||
} else if (kind === "subtitle") {
|
||||
if (!SUBTITLE_SITES.some((d) => d.key === body.key) || body.adapter !== "subtitle")
|
||||
throw new ApiException("bad_request", "字幕站要从内置字幕站里选(SubHD、字幕库)");
|
||||
} else if (publicDef) {
|
||||
if (body.adapter !== adapterOf(publicDef))
|
||||
throw new ApiException("bad_request", `站点 ${body.key} 的适配器类型应为 ${adapterOf(publicDef)}`);
|
||||
|
||||
@@ -810,7 +810,20 @@ export async function checkSiteHealth(
|
||||
let message: string | null = null;
|
||||
let detail: SiteHealthDetail | null = null;
|
||||
const def = getSiteDefinition(site.key);
|
||||
if (!def) {
|
||||
if (site.kind === "subtitle") {
|
||||
// 字幕站:用站点出口(代理 / UA / Cloudflare 处理)打开首页,2xx 即可用
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const res = await rt.siteFetch(site)(site.base_url, { signal: AbortSignal.timeout(20_000) });
|
||||
await res.body?.cancel();
|
||||
ok = res.ok;
|
||||
status = res.ok ? "ok" : "unreachable";
|
||||
latencyMs = Date.now() - t0;
|
||||
message = res.ok ? null : `首页返回 HTTP ${res.status}`;
|
||||
} catch (e) {
|
||||
message = (e as Error).message.slice(0, 300);
|
||||
}
|
||||
} else if (!def) {
|
||||
message = "站点没有适配器定义,无法检测";
|
||||
status = "unsupported";
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Api, type SubtitleApplyRequest } from "@umt/contracts";
|
||||
import { Api, type SubtitleApplyRequest, type SubtitleFetchRequest, type SubtitleSource } from "@umt/contracts";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import { route } from "../agent/http.ts";
|
||||
import { sourcesIdentityModule } from "../sources-identity/index.ts";
|
||||
import type { ServerModule } from "../types.ts";
|
||||
import { downloadOnline, SubtitleSiteError, searchOnline, subtitleSearchQuery } from "./online/index.ts";
|
||||
import { applySubtitles, parseSubtitles, subtitleTargets, subtitleWrites, undoSubtitleWrite } from "./service.ts";
|
||||
|
||||
/** 补字幕(docs/plan/subtitles.md):上传 / 在线下载的字幕解析、配对、写入、历史与撤销 */
|
||||
@@ -9,8 +12,39 @@ export function createSubtitlesModule(): ServerModule {
|
||||
name: "subtitles",
|
||||
registerRoutes(app, ctx) {
|
||||
const tag = { tag: "subtitles" };
|
||||
const rt = () => sourcesIdentityModule.runtime(ctx);
|
||||
const siteErr = async <T>(fn: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
if (e instanceof SubtitleSiteError) throw new ApiException("upstream_unavailable", e.message);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
route(app, Api.subtitles.targets, ({ query }) => subtitleTargets(ctx, query.entityId), tag);
|
||||
route(app, Api.subtitles.parse, ({ body }) => parseSubtitles(ctx, body.files, body.entityId), tag);
|
||||
route(
|
||||
app,
|
||||
Api.subtitles.search,
|
||||
({ body }) => siteErr(async () => searchOnline(ctx, rt(), await subtitleSearchQuery(ctx, body.entityId, body))),
|
||||
tag,
|
||||
);
|
||||
route(
|
||||
app,
|
||||
Api.subtitles.fetch,
|
||||
({ body }) =>
|
||||
siteErr(async () => {
|
||||
const b = body as SubtitleFetchRequest;
|
||||
const f = await downloadOnline(ctx, rt(), b.siteId, b.ref);
|
||||
const parsed = await parseSubtitles(ctx, [{ name: f.name, base64: f.base64 }], b.entityId);
|
||||
// 在线来源:每项带上字幕站与条目地址,写入时记进历史
|
||||
return {
|
||||
...parsed,
|
||||
items: parsed.items.map((it) => ({ ...it, source: f.siteKey as SubtitleSource, sourceRef: f.pageUrl })),
|
||||
};
|
||||
}),
|
||||
tag,
|
||||
);
|
||||
route(
|
||||
app,
|
||||
Api.subtitles.apply,
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
// 在线字幕(docs/plan/subtitles.md 第 2 步):在已启用的字幕站(site.kind = subtitle)搜索、下载。
|
||||
// 请求走站点出口 rt.siteFetch(站点代理、UA、Cloudflare 挑战走浏览器);页面解析与验证码识别在沙箱里(ADR-005)。
|
||||
// 流程对照 MP 插件 online_subtitles/providers/{subhd,zimuku}.py,页面结构 2026-10-03 实测。
|
||||
import type { SubtitleSearchResponse, SubtitleSearchResult } from "@umt/contracts";
|
||||
import type { SiteRow } from "@umt/db";
|
||||
import { subtitleEpisode } from "@umt/domain";
|
||||
import { runSandbox } from "../../../sandbox/client.ts";
|
||||
import { b64encode } from "../../../sandbox/jobs.ts";
|
||||
import type { SourcesRuntime } from "../../sources-identity/runtime.ts";
|
||||
import type { ServerContext } from "../../types.ts";
|
||||
import type { PageEntry, PageKind, PageResult } from "./parse.ts";
|
||||
|
||||
const MAX_DOWNLOAD_BYTES = 30 * 1024 * 1024;
|
||||
|
||||
/** 站内操作失败(给用户看的原因) */
|
||||
export class SubtitleSiteError extends Error {
|
||||
override name = "SubtitleSiteError";
|
||||
}
|
||||
|
||||
// ── 请求:站点出口 + 每站一个 Cookie 罐(字幕库的验证 Cookie、SubHD 的会话) ──
|
||||
|
||||
const jars = new Map<string, Map<string, string>>();
|
||||
|
||||
interface Env {
|
||||
rt: SourcesRuntime;
|
||||
site: SiteRow;
|
||||
}
|
||||
|
||||
async function request(
|
||||
env: Env,
|
||||
url: string,
|
||||
init: { referer?: string; json?: unknown; signal?: AbortSignal } = {},
|
||||
): Promise<Response> {
|
||||
const jar = jars.get(env.site.id) ?? new Map<string, string>();
|
||||
jars.set(env.site.id, jar);
|
||||
const headers: Record<string, string> = { Accept: "text/html,application/json;q=0.9,*/*;q=0.8" };
|
||||
if (jar.size) headers.cookie = [...jar].map(([k, v]) => `${k}=${v}`).join("; ");
|
||||
if (init.referer) headers.referer = init.referer;
|
||||
if (init.json !== undefined) headers["content-type"] = "application/json";
|
||||
let res: Response;
|
||||
try {
|
||||
res = await env.rt.siteFetch(env.site)(url, {
|
||||
method: init.json === undefined ? "GET" : "POST",
|
||||
headers,
|
||||
...(init.json !== undefined ? { body: JSON.stringify(init.json) } : {}),
|
||||
signal: init.signal ?? AbortSignal.timeout(30_000),
|
||||
});
|
||||
} catch (e) {
|
||||
const n = (e as Error).name;
|
||||
if (n === "TimeoutError" || n === "AbortError")
|
||||
throw new SubtitleSiteError(`连接${env.site.name}超时,稍后再试(站点可能在限流或网络不通)`);
|
||||
throw new SubtitleSiteError(`连接${env.site.name}失败:${(e as Error).message.slice(0, 200)}`);
|
||||
}
|
||||
for (const c of res.headers.getSetCookie()) {
|
||||
const kv = c.split(";")[0] ?? "";
|
||||
const i = kv.indexOf("=");
|
||||
if (i > 0) jar.set(kv.slice(0, i).trim(), kv.slice(i + 1).trim());
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async function getText(env: Env, url: string, referer?: string): Promise<string> {
|
||||
const res = await request(env, url, referer ? { referer } : {});
|
||||
if (!res.ok) throw new SubtitleSiteError(`${env.site.name} 返回 HTTP ${res.status}`);
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
const page = <K extends PageKind>(kind: K, html: string) =>
|
||||
runSandbox("subtitle.page", { kind, html }, { timeoutMs: 15_000 }) as Promise<PageResult<K>>;
|
||||
|
||||
const abs = (env: Env, path: string) => new URL(path, env.site.base_url).href;
|
||||
|
||||
/** content-disposition 里的文件名:常见是 UTF-8 字节被当成 latin1 发出,按字节还原 */
|
||||
function fileNameOf(res: Response, url: string): string {
|
||||
const cd = res.headers.get("content-disposition") ?? "";
|
||||
const star = /filename\*=UTF-8''([^;]+)/i.exec(cd)?.[1];
|
||||
if (star) return decodeURIComponent(star);
|
||||
const plain = /filename="?([^";]+)"?/i.exec(cd)?.[1];
|
||||
if (plain) {
|
||||
const bytes = Uint8Array.from(plain, (c) => c.charCodeAt(0) & 0xff);
|
||||
const utf8 = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
||||
return utf8.includes("�") ? plain : utf8;
|
||||
}
|
||||
return decodeURIComponent(new URL(url).pathname.split("/").pop() || "subtitle.zip");
|
||||
}
|
||||
|
||||
async function getFile(env: Env, url: string, referer: string): Promise<{ name: string; bytes: Uint8Array }> {
|
||||
const res = await request(env, url, { referer });
|
||||
if (!res.ok) throw new SubtitleSiteError(`下载返回 HTTP ${res.status}`);
|
||||
const len = Number(res.headers.get("content-length") ?? 0);
|
||||
if (len > MAX_DOWNLOAD_BYTES) throw new SubtitleSiteError("字幕包超过 30 MB");
|
||||
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||
if (bytes.length > MAX_DOWNLOAD_BYTES) throw new SubtitleSiteError("字幕包超过 30 MB");
|
||||
const head = new TextDecoder().decode(bytes.subarray(0, 200)).toLowerCase();
|
||||
if (/<!doctype html|<html/.test(head)) throw new SubtitleSiteError("下载地址返回的是网页,不是字幕文件");
|
||||
return { name: fileNameOf(res, res.url || url), bytes };
|
||||
}
|
||||
|
||||
// ── 搜索词与匹配 ──
|
||||
|
||||
export interface SearchQuery {
|
||||
keywords: string[];
|
||||
doubanId: string | null;
|
||||
season: number | null;
|
||||
episode: number | null;
|
||||
}
|
||||
|
||||
const norm = (s: string) => s.toLowerCase().replace(/[\s\p{P}\p{S}]/gu, "");
|
||||
|
||||
/** 标题里含任一搜索词(去掉空白与标点比较) */
|
||||
function relevant(title: string, keywords: string[]): boolean {
|
||||
const t = norm(title);
|
||||
return keywords.some((k) => {
|
||||
const n = norm(k);
|
||||
return n.length >= 2 && t.includes(n);
|
||||
});
|
||||
}
|
||||
|
||||
/** 季集不符的丢掉;合集 / 季包保留 */
|
||||
function episodeOk(title: string, q: SearchQuery): { ok: boolean; season: number | null; episode: number | null } {
|
||||
const ep = subtitleEpisode(`${title}.srt`);
|
||||
if (q.season !== null && !ep.seasonInferred && ep.season !== null && ep.season !== q.season)
|
||||
return { ok: false, season: ep.season, episode: ep.episode };
|
||||
if (q.episode !== null && ep.episode !== null && ep.episode !== q.episode)
|
||||
return { ok: false, season: ep.season, episode: ep.episode };
|
||||
return { ok: true, season: ep.seasonInferred ? null : ep.season, episode: ep.episode };
|
||||
}
|
||||
|
||||
const LANG_SCORE: Record<string, number> = { 双语: 3, 简体: 2, 繁体: 1 };
|
||||
|
||||
function toResult(env: Env, e: PageEntry, q: SearchQuery): SubtitleSearchResult | null {
|
||||
const ep = episodeOk(e.title, q);
|
||||
if (!ep.ok) return null;
|
||||
const lang = Math.max(0, ...e.languages.map((l) => LANG_SCORE[l] ?? 0));
|
||||
const exact = q.episode !== null && ep.episode === q.episode ? 2 : 0;
|
||||
return {
|
||||
siteId: env.site.id,
|
||||
siteKey: env.site.key,
|
||||
siteName: env.site.name,
|
||||
ref: e.ref,
|
||||
title: e.title,
|
||||
pageUrl: abs(env, e.ref),
|
||||
languages: e.languages,
|
||||
format: e.format,
|
||||
downloads: e.downloads,
|
||||
uploader: e.uploader,
|
||||
publishedAt: e.publishedAt,
|
||||
season: ep.season,
|
||||
episode: ep.episode,
|
||||
score: lang * 10 + exact * 5 + Math.log10((e.downloads ?? 0) + 1),
|
||||
};
|
||||
}
|
||||
|
||||
// ── SubHD ──
|
||||
|
||||
async function subhdSearch(env: Env, q: SearchQuery): Promise<PageEntry[]> {
|
||||
// SubHD 的条目 ID 就是豆瓣条目 ID:有豆瓣 ID 直接打开条目页
|
||||
if (q.doubanId) {
|
||||
const d = await page("subhd.list", await getText(env, abs(env, `/d/${q.doubanId}`), env.site.base_url));
|
||||
if (d.entries.length) return d.entries;
|
||||
}
|
||||
for (const kw of q.keywords.slice(0, 3)) {
|
||||
const html = await getText(env, abs(env, `/search/${encodeURIComponent(kw)}`), env.site.base_url);
|
||||
const r = await page("subhd.list", html);
|
||||
const hits = r.entries.filter((e) => relevant(e.title, q.keywords));
|
||||
if (hits.length) return hits;
|
||||
for (const d of r.details.filter((x) => relevant(x.title, q.keywords)).slice(0, 2)) {
|
||||
const dd = await page("subhd.list", await getText(env, abs(env, `/d/${d.id}`), env.site.base_url));
|
||||
if (dd.entries.length) return dd.entries;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function subhdDownload(env: Env, ref: string): Promise<{ name: string; bytes: Uint8Array }> {
|
||||
const subUrl = abs(env, ref);
|
||||
const sub = await page("subhd.sub", await getText(env, subUrl, env.site.base_url));
|
||||
let down = sub.down;
|
||||
if (!down && sub.dataSid) {
|
||||
const prep = (await (
|
||||
await request(env, abs(env, "/api/sub/prepare-download"), { referer: subUrl, json: { sid: sub.dataSid } })
|
||||
).json()) as { success?: boolean; url?: string };
|
||||
if (prep.success && prep.url?.includes("/down/")) down = prep.url;
|
||||
}
|
||||
if (!down) throw new SubtitleSiteError("SubHD 字幕页没有下载按钮");
|
||||
const downUrl = abs(env, down);
|
||||
const sid = new URL(downUrl).pathname.split("/").pop() ?? "";
|
||||
await (await request(env, downUrl, { referer: subUrl })).body?.cancel();
|
||||
const api = async (cap: string) =>
|
||||
(await (await request(env, abs(env, "/api/sub/down"), { referer: downUrl, json: { sid, cap } })).json()) as {
|
||||
success?: boolean;
|
||||
pass?: boolean;
|
||||
msg?: string;
|
||||
url?: string;
|
||||
};
|
||||
let r = await api("");
|
||||
if (r.pass === false) {
|
||||
// SVG 文字验证码:沙箱里识别后带上再提交一次
|
||||
const { code } = await runSandbox("captcha.solve", { kind: "subhd", svg: String(r.msg ?? "") });
|
||||
r = await api(code ?? "");
|
||||
}
|
||||
if (!r.success || !r.url)
|
||||
throw new SubtitleSiteError("SubHD 下载验证没通过:可在站点的原站视图里打开这条字幕手动下载后上传");
|
||||
return await getFile(env, abs(env, r.url), downUrl);
|
||||
}
|
||||
|
||||
// ── 字幕库 ──
|
||||
|
||||
/** 打开页面;遇到云锁验证码页就在沙箱里识别、提交验证后重开(最多 3 次) */
|
||||
async function zimukuPage(env: Env, url: string, referer?: string): Promise<string> {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await request(env, url, referer ? { referer } : {});
|
||||
const html = await res.text();
|
||||
const cap = await page("zimuku.captcha", html);
|
||||
if (!cap.bmpBase64) {
|
||||
if (!res.ok) throw new SubtitleSiteError(`${env.site.name} 返回 HTTP ${res.status}`);
|
||||
return html;
|
||||
}
|
||||
const { code } = await runSandbox("captcha.solve", { kind: "zimuku", bmpBase64: cap.bmpBase64 });
|
||||
if (!code) break;
|
||||
const hex = [...code].map((c) => c.charCodeAt(0).toString(16)).join("");
|
||||
await (await request(env, `${url}${url.includes("?") ? "&" : "?"}security_verify_img=${hex}`)).body?.cancel();
|
||||
}
|
||||
throw new SubtitleSiteError("字幕库的验证码没能自动通过:可在站点的原站视图里打开一次再试");
|
||||
}
|
||||
|
||||
async function zimukuSearch(env: Env, q: SearchQuery): Promise<PageEntry[]> {
|
||||
for (const kw of q.keywords.slice(0, 3)) {
|
||||
const s = await page("zimuku.search", await zimukuPage(env, abs(env, `/search?q=${encodeURIComponent(kw)}`)));
|
||||
const subs = s.subs.filter((x) => !x.title || relevant(x.title, q.keywords)).slice(0, 3);
|
||||
const out: PageEntry[] = [];
|
||||
for (const x of subs) {
|
||||
const p = await page("zimuku.subs", await zimukuPage(env, abs(env, x.path), env.site.base_url));
|
||||
out.push(...p.entries);
|
||||
}
|
||||
if (out.length) return out;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function zimukuDownload(env: Env, ref: string): Promise<{ name: string; bytes: Uint8Array }> {
|
||||
const detailUrl = abs(env, ref);
|
||||
const d = await page("zimuku.detail", await zimukuPage(env, detailUrl, env.site.base_url));
|
||||
if (!d.dl) throw new SubtitleSiteError("字幕库字幕页没有下载入口");
|
||||
const dlUrl = new URL(d.dl, detailUrl).href;
|
||||
const links = (await page("zimuku.dl", await zimukuPage(env, dlUrl, detailUrl))).links;
|
||||
let last = "字幕库没有可用的下载线路";
|
||||
for (const l of links) {
|
||||
try {
|
||||
const f = await getFile(env, new URL(l, dlUrl).href, dlUrl);
|
||||
if (f.bytes.length > 64) return f;
|
||||
} catch (e) {
|
||||
last = (e as Error).message;
|
||||
}
|
||||
}
|
||||
throw new SubtitleSiteError(last);
|
||||
}
|
||||
|
||||
const PROVIDERS: Record<
|
||||
string,
|
||||
{
|
||||
search(env: Env, q: SearchQuery): Promise<PageEntry[]>;
|
||||
download(env: Env, ref: string): Promise<{ name: string; bytes: Uint8Array }>;
|
||||
}
|
||||
> = {
|
||||
subhd: { search: subhdSearch, download: subhdDownload },
|
||||
zimuku: { search: zimukuSearch, download: zimukuDownload },
|
||||
};
|
||||
|
||||
// ── 对外 ──
|
||||
|
||||
export async function subtitleSearchQuery(
|
||||
ctx: ServerContext,
|
||||
entityId: string,
|
||||
opts: { season?: number | undefined; episode?: number | undefined; keyword?: string | undefined },
|
||||
): Promise<SearchQuery> {
|
||||
const e = await ctx.db
|
||||
.selectFrom("media_entity")
|
||||
.select(["title", "original_title"])
|
||||
.where("id", "=", entityId)
|
||||
.executeTakeFirst();
|
||||
if (!e) throw new SubtitleSiteError("作品不存在");
|
||||
const aliases = await ctx.db
|
||||
.selectFrom("title_alias")
|
||||
.select("alias")
|
||||
.where("media_entity_id", "=", entityId)
|
||||
.where("content_unit_id", "is", null)
|
||||
.limit(5)
|
||||
.execute();
|
||||
const douban = await ctx.db
|
||||
.selectFrom("external_identity")
|
||||
.select("external_id")
|
||||
.where("media_entity_id", "=", entityId)
|
||||
.where("provider", "=", "douban")
|
||||
.where("status", "=", "accepted")
|
||||
.executeTakeFirst();
|
||||
const keywords = [opts.keyword, e.title, e.original_title, ...aliases.map((a) => a.alias)]
|
||||
.map((x) => x?.trim())
|
||||
.filter((x): x is string => !!x && x.length >= 2);
|
||||
return {
|
||||
keywords: [...new Set(keywords)],
|
||||
doubanId: douban?.external_id ?? null,
|
||||
season: opts.season ?? null,
|
||||
episode: opts.episode ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function subtitleSites(ctx: ServerContext): Promise<SiteRow[]> {
|
||||
return await ctx.db
|
||||
.selectFrom("site")
|
||||
.selectAll()
|
||||
.where("kind", "=", "subtitle")
|
||||
.where("enabled", "=", true)
|
||||
.where("deleted_at", "is", null)
|
||||
.orderBy("name")
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function searchOnline(
|
||||
ctx: ServerContext,
|
||||
rt: SourcesRuntime,
|
||||
q: SearchQuery,
|
||||
): Promise<SubtitleSearchResponse> {
|
||||
const sites = (await subtitleSites(ctx)).filter((s) => PROVIDERS[s.key]);
|
||||
const results: SubtitleSearchResult[] = [];
|
||||
const errors: SubtitleSearchResponse["errors"] = [];
|
||||
await Promise.all(
|
||||
sites.map(async (site) => {
|
||||
const env = { rt, site };
|
||||
try {
|
||||
const entries = await PROVIDERS[site.key]!.search(env, q);
|
||||
const seen = new Set<string>();
|
||||
for (const e of entries) {
|
||||
if (seen.has(e.ref)) continue;
|
||||
seen.add(e.ref);
|
||||
const r = toResult(env, e, q);
|
||||
if (r) results.push(r);
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push({ siteName: site.name, reason: (e as Error).message.slice(0, 300) });
|
||||
}
|
||||
}),
|
||||
);
|
||||
results.sort((a, b) => b.score - a.score);
|
||||
return { results: results.slice(0, 60), keywords: q.keywords, errors, noSites: sites.length === 0 };
|
||||
}
|
||||
|
||||
export async function downloadOnline(
|
||||
ctx: ServerContext,
|
||||
rt: SourcesRuntime,
|
||||
siteId: string,
|
||||
ref: string,
|
||||
): Promise<{ name: string; base64: string; siteKey: string; pageUrl: string }> {
|
||||
const site = (await subtitleSites(ctx)).find((s) => s.id === siteId);
|
||||
if (!site || !PROVIDERS[site.key]) throw new SubtitleSiteError("字幕站不存在或已停用");
|
||||
const env = { rt, site };
|
||||
const f = await PROVIDERS[site.key]!.download(env, ref);
|
||||
return { name: f.name, base64: b64encode(f.bytes), siteKey: site.key, pageUrl: abs(env, ref) };
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// 字幕站页面解析(纯函数,在沙箱任务 subtitle.page 里跑):SubHD / 字幕库的搜索页、条目页、字幕页、下载页 → 结构化数据。
|
||||
// 页面结构 2026-10-03 实测(样本在 .local/site-samples/subs/),对照 MP 插件 online_subtitles/providers/{subhd,zimuku}.py。
|
||||
|
||||
export interface PageEntry {
|
||||
/** 站内引用:SubHD 为 /a/<sid>,字幕库为 /detail/<id>.html */
|
||||
ref: string;
|
||||
title: string;
|
||||
/** 简体 / 繁体 / 双语 / 英文 …(站点标注或从标题、说明里猜) */
|
||||
languages: string[];
|
||||
format: string | null;
|
||||
downloads: number | null;
|
||||
uploader: string | null;
|
||||
publishedAt: string | null;
|
||||
}
|
||||
|
||||
const strip = (s: string) =>
|
||||
s
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const href = (q: string) => `href=["']${q}["']`;
|
||||
|
||||
/** 从标题 / 说明里猜语言 */
|
||||
export function guessLanguages(text: string): string[] {
|
||||
const t = text.toLowerCase();
|
||||
const out: string[] = [];
|
||||
if (/双语|中英|简英|繁英|chs&eng|cht&eng|bilingual/.test(t)) out.push("双语");
|
||||
if (/简体|简中|chs|gb|简繁|简日/.test(t)) out.push("简体");
|
||||
if (/繁体|繁體|繁中|cht|big5|简繁/.test(t)) out.push("繁体");
|
||||
if (/英文|英语|\beng?\b|english/.test(t) && !out.includes("双语")) out.push("英文");
|
||||
return [...new Set(out)];
|
||||
}
|
||||
|
||||
const formatOf = (text: string) => /\b(ass|ssa|srt|sup|vtt|sub|idx)\b/i.exec(text)?.[1]?.toUpperCase() ?? null;
|
||||
const num = (s: string | undefined) => {
|
||||
if (!s) return null;
|
||||
const m = /([\d.]+)\s*(万)?/.exec(s.replace(/,/g, ""));
|
||||
return m ? Math.round(Number(m[1]) * (m[2] ? 10_000 : 1)) : null;
|
||||
};
|
||||
|
||||
// ── SubHD ──
|
||||
|
||||
/** 搜索页 / 条目页(/d/<豆瓣 ID>):结果区里的字幕(/a/<sid>),不含侧栏热门 */
|
||||
function subhdList(html: string): { entries: PageEntry[]; details: { id: string; title: string }[] } {
|
||||
const hot = html.search(/<h\d[^>]*>\s*热门|class="[^"]*hot[^"]*"/);
|
||||
const body = hot > 0 ? html.slice(0, hot) : html;
|
||||
const entries: PageEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const m of body.matchAll(new RegExp(`<a[^>]+${href("/a/([A-Za-z0-9_-]+)")}[^>]*>([\\s\\S]*?)</a>`, "g"))) {
|
||||
const sid = m[1]!;
|
||||
const title = strip(m[2]!);
|
||||
if (seen.has(sid) || !title) continue;
|
||||
seen.add(sid);
|
||||
const around = strip(body.slice(Math.max(0, m.index! - 600), m.index! + 1200));
|
||||
entries.push({
|
||||
ref: `/a/${sid}`,
|
||||
title,
|
||||
languages: guessLanguages(`${title} ${around}`),
|
||||
format: formatOf(`${title} ${around}`),
|
||||
downloads: null,
|
||||
uploader: /发布人\s*(\S+)/.exec(around)?.[1] ?? null,
|
||||
publishedAt: /<time datetime="([^"]+)"/.exec(body.slice(m.index!, m.index! + 2500))?.[1] ?? null,
|
||||
});
|
||||
}
|
||||
const details: { id: string; title: string }[] = [];
|
||||
for (const m of body.matchAll(new RegExp(`<a[^>]+${href("/d/(\\d+)")}[^>]*>([\\s\\S]*?)</a>`, "g"))) {
|
||||
const alt = /alt=["']([^"']+)["']/.exec(m[2]!)?.[1];
|
||||
const title = strip(m[2]!) || alt || "";
|
||||
if (title && !details.some((d) => d.id === m[1])) details.push({ id: m[1]!, title });
|
||||
}
|
||||
return { entries, details };
|
||||
}
|
||||
|
||||
/** 字幕页(/a/<sid>):下载按钮 /down/<sid> 或 data-sid(要先 prepare-download) */
|
||||
function subhdSub(html: string): { down: string | null; dataSid: string | null } {
|
||||
return {
|
||||
down: new RegExp(`${href("([^\"']*/down/[A-Za-z0-9_-]+[^\"']*)")}`).exec(html)?.[1] ?? null,
|
||||
dataSid: /\bdata-sid=["']([A-Za-z0-9_-]+)["']/.exec(html)?.[1] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 字幕库 ──
|
||||
|
||||
/** 字幕库语言旗帜的说明(「简体中文字幕」「双语字幕」…)→ 统一叫法 */
|
||||
function langLabel(t: string): string {
|
||||
if (/双语/.test(t)) return "双语";
|
||||
if (/繁/.test(t)) return "繁体";
|
||||
if (/简/.test(t)) return "简体";
|
||||
if (/英/.test(t)) return "英文";
|
||||
if (/日/.test(t)) return "日文";
|
||||
if (/韩|韓/.test(t)) return "韩文";
|
||||
return t.replace(/字幕$/, "");
|
||||
}
|
||||
|
||||
function zimukuSearch(html: string): { subs: { path: string; title: string }[] } {
|
||||
const subs: { path: string; title: string }[] = [];
|
||||
for (const m of html.matchAll(/<a[^>]+href=["']([^"']*\/subs\/\d+\.html)["'][^>]*>([\s\S]*?)<\/a>/g)) {
|
||||
// 链接可能写成 //zimuku.org/subs/…(别的域名):只取路径,拼到站点地址上
|
||||
const path = /\/subs\/\d+\.html/.exec(m[1]!)![0];
|
||||
const title =
|
||||
strip(m[2]!) || /title=["']([^"']+)["']/.exec(m[0])?.[1] || /alt=["']([^"']+)["']/.exec(m[2]!)?.[1] || "";
|
||||
const prev = subs.find((s) => s.path === path);
|
||||
if (!prev) subs.push({ path, title });
|
||||
else if (!prev.title && title) prev.title = title;
|
||||
}
|
||||
return { subs };
|
||||
}
|
||||
|
||||
/** 条目页(/subs/<id>.html)的字幕表 */
|
||||
function zimukuSubs(html: string): { entries: PageEntry[] } {
|
||||
const entries: PageEntry[] = [];
|
||||
for (const row of html.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/g)) {
|
||||
const r = row[1]!;
|
||||
const a = /<a href=["'](\/detail\/\d+\.html)["'][^>]*title=["']([^"']+)["']/.exec(r);
|
||||
if (!a) continue;
|
||||
const langs = [...r.matchAll(/<img[^>]+title=["']([^"']+字幕)["']/g)].map((m) => langLabel(m[1]!));
|
||||
const cells = [...r.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/g)].map((m) => m[1]!);
|
||||
entries.push({
|
||||
ref: a[1]!,
|
||||
title: strip(a[2]!),
|
||||
languages: langs.length ? [...new Set(langs)] : guessLanguages(a[2]!),
|
||||
format: /<span class="label label-info">([^<]+)<\/span>/.exec(r)?.[1]?.trim() ?? formatOf(a[2]!),
|
||||
downloads: num(strip(cells[4] ?? "")),
|
||||
uploader: /<a href=["']\/u\/[^"']+["']>([^<]+)<\/a>/.exec(r)?.[1] ?? null,
|
||||
publishedAt: strip(/glyphicon-time"><\/span>([^<]+)</.exec(r)?.[1] ?? "") || null,
|
||||
});
|
||||
}
|
||||
return { entries };
|
||||
}
|
||||
|
||||
/** 字幕页:下载入口(li.dlsub 里的链接) */
|
||||
function zimukuDetail(html: string): { dl: string | null } {
|
||||
return {
|
||||
dl: /<li[^>]+class=["'][^"']*dlsub[^"']*["'][^>]*>[\s\S]*?<a[^>]+href=["']([^"']+)["']/.exec(html)?.[1] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 下载跳转页:各线路的 /download/ 链接 */
|
||||
function zimukuDl(html: string): { links: string[] } {
|
||||
return { links: [...new Set([...html.matchAll(/href=["']([^"']*\/download\/[^"']+)["']/g)].map((m) => m[1]!))] };
|
||||
}
|
||||
|
||||
/** 是否是字幕库(云锁)的验证码页;是则带回 BMP */
|
||||
function zimukuCaptcha(html: string): { bmpBase64: string | null } {
|
||||
return {
|
||||
bmpBase64:
|
||||
/class=["']verifyimg["'][^>]*src=["']data:image\/bmp;base64,([^"']+)["']/.exec(html)?.[1] ??
|
||||
/data:image\/bmp;base64,([^"']+)/.exec(html)?.[1] ??
|
||||
null,
|
||||
};
|
||||
}
|
||||
|
||||
export const PAGE_PARSERS = {
|
||||
"subhd.list": subhdList,
|
||||
"subhd.sub": subhdSub,
|
||||
"zimuku.search": zimukuSearch,
|
||||
"zimuku.subs": zimukuSubs,
|
||||
"zimuku.detail": zimukuDetail,
|
||||
"zimuku.dl": zimukuDl,
|
||||
"zimuku.captcha": zimukuCaptcha,
|
||||
} as const;
|
||||
export type PageKind = keyof typeof PAGE_PARSERS;
|
||||
export type PageResult<K extends PageKind> = ReturnType<(typeof PAGE_PARSERS)[K]>;
|
||||
@@ -269,7 +269,7 @@ export async function applySubtitles(
|
||||
library_root_id: t.libraryRootId,
|
||||
relative_path: rel,
|
||||
language: it.language,
|
||||
source: body.source ?? "upload",
|
||||
source: it.source ?? body.source ?? "upload",
|
||||
source_name: it.name,
|
||||
source_ref: it.sourceRef ?? null,
|
||||
backup_path: backup,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// 压缩包解压(沙箱内):zip 用 fflate,rar 用 node-unrar-js(WASM),7z 用 7z-wasm。
|
||||
// WASM 库要读自己包里的 .wasm 文件:prepareArchives() 在撤销权限前预热(载入内存),之后只在内存里解压。
|
||||
import SevenZip from "7z-wasm";
|
||||
import { unzipSync } from "fflate";
|
||||
import { createExtractorFromData } from "node-unrar-js";
|
||||
|
||||
type Seven = Awaited<ReturnType<typeof SevenZip>>;
|
||||
let seven: Seven | null = null;
|
||||
let warmed = false;
|
||||
|
||||
export async function prepareArchives(): Promise<void> {
|
||||
if (warmed) return;
|
||||
warmed = true;
|
||||
try {
|
||||
await createExtractorFromData({ data: new Uint8Array([0x52, 0x61, 0x72, 0x21]).buffer });
|
||||
} catch {
|
||||
// 只为载入 WASM,内容无效报错是预期的
|
||||
}
|
||||
seven = await SevenZip({ print: () => {}, printErr: () => {} });
|
||||
}
|
||||
|
||||
export type AcceptEntry = (name: string, size: number) => boolean;
|
||||
|
||||
export function archiveKindOf(name: string): "zip" | "rar" | "7z" | null {
|
||||
const m = /\.(zip|rar|7z)$/i.exec(name);
|
||||
return m ? (m[1]!.toLowerCase() as "zip" | "rar" | "7z") : null;
|
||||
}
|
||||
|
||||
/** 解出通过 accept 的条目;压缩包损坏 / 加密抛错 */
|
||||
export async function extractArchive(
|
||||
kind: "zip" | "rar" | "7z",
|
||||
bytes: Uint8Array,
|
||||
accept: AcceptEntry,
|
||||
): Promise<Record<string, Uint8Array>> {
|
||||
if (kind === "zip")
|
||||
return unzipSync(bytes, { filter: (e) => !e.name.endsWith("/") && accept(e.name, e.originalSize) });
|
||||
if (kind === "rar") {
|
||||
const ex = await createExtractorFromData({ data: bytes.slice().buffer });
|
||||
const out: Record<string, Uint8Array> = {};
|
||||
for (const f of ex.extract({ files: (h) => !h.flags.directory && accept(h.name, h.unpSize) }).files)
|
||||
if (f.extraction) out[f.fileHeader.name] = f.extraction;
|
||||
return out;
|
||||
}
|
||||
await prepareArchives();
|
||||
const z = seven!;
|
||||
const dir = `/x${crypto.randomUUID().slice(0, 8)}`;
|
||||
z.FS.writeFile(`${dir}.7z`, bytes);
|
||||
// callMain 出错时抛 ExitStatus 或静默不写文件:两种都当成损坏处理
|
||||
z.callMain(["x", `${dir}.7z`, `-o${dir}`, "-y", "-p-"]);
|
||||
try {
|
||||
z.FS.stat(dir);
|
||||
} catch {
|
||||
throw new Error("7z 解压失败");
|
||||
}
|
||||
const out: Record<string, Uint8Array> = {};
|
||||
const walk = (d: string, rel: string) => {
|
||||
for (const n of z.FS.readdir(d) as string[]) {
|
||||
if (n === "." || n === "..") continue;
|
||||
const p = `${d}/${n}`;
|
||||
const r = rel ? `${rel}/${n}` : n;
|
||||
if (z.FS.isDir(z.FS.stat(p).mode)) walk(p, r);
|
||||
else {
|
||||
const data = z.FS.readFile(p) as Uint8Array;
|
||||
if (accept(r, data.length)) out[r] = data;
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(dir, "");
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// SubHD 下载验证码(SVG 文字):每个字符一条长 path,按 path 长度查表,长度撞车时看最高点 / 起笔 / 宽度区分
|
||||
// (移植自 MP 插件 captcha/subhd.py 的长度表与判别规则)
|
||||
const LENGTH_MAP: Record<number, string> = {
|
||||
986: "I",
|
||||
998: "1",
|
||||
1068: "I",
|
||||
1081: "1",
|
||||
1082: "v",
|
||||
1130: "Y",
|
||||
1134: "Y",
|
||||
1172: "v",
|
||||
1224: "Y",
|
||||
1274: "L",
|
||||
1298: "V",
|
||||
1311: "V",
|
||||
1360: "i",
|
||||
1380: "L",
|
||||
1406: "V",
|
||||
1473: "i",
|
||||
1478: "T",
|
||||
1491: "r",
|
||||
1598: "N",
|
||||
1601: "T",
|
||||
1604: "X",
|
||||
1610: "J",
|
||||
1613: "x",
|
||||
1614: "N",
|
||||
1615: "N",
|
||||
1616: "N",
|
||||
1617: "N",
|
||||
1618: "N",
|
||||
1634: "k",
|
||||
1637: "k",
|
||||
1694: "t",
|
||||
1706: "K",
|
||||
1709: "K",
|
||||
1731: "N",
|
||||
1744: "J",
|
||||
1754: "F",
|
||||
1770: "k",
|
||||
1835: "t",
|
||||
1838: "u",
|
||||
1840: "A",
|
||||
1844: "A",
|
||||
1848: "K",
|
||||
1850: "Z",
|
||||
1853: "Z",
|
||||
1886: "h",
|
||||
1900: "F",
|
||||
1922: "H",
|
||||
1928: "H",
|
||||
1960: "P",
|
||||
1991: "u",
|
||||
1993: "A",
|
||||
1996: "D",
|
||||
2004: "Z",
|
||||
2018: "w",
|
||||
2035: "w",
|
||||
2042: "7",
|
||||
2043: "h",
|
||||
2080: "j",
|
||||
2082: "H",
|
||||
2104: "R",
|
||||
2107: "R",
|
||||
2123: "P",
|
||||
2140: "4",
|
||||
2162: "D",
|
||||
2164: "O",
|
||||
2183: "w",
|
||||
2198: "C",
|
||||
2199: "C",
|
||||
2200: "C",
|
||||
2201: "C",
|
||||
2202: "C",
|
||||
2210: "f",
|
||||
2212: "7",
|
||||
2246: "E",
|
||||
2253: "j",
|
||||
2260: "o",
|
||||
2272: "d",
|
||||
2279: "M",
|
||||
2282: "M",
|
||||
2294: "U",
|
||||
2301: "U",
|
||||
2310: "W",
|
||||
2318: "4",
|
||||
2321: "M",
|
||||
2332: "a",
|
||||
2344: "O",
|
||||
2345: "W",
|
||||
2346: "W",
|
||||
2366: "s",
|
||||
2380: "b",
|
||||
2381: "C",
|
||||
2382: "0",
|
||||
2394: "f",
|
||||
2433: "E",
|
||||
2448: "o",
|
||||
2461: "d",
|
||||
2464: "p",
|
||||
2466: "M",
|
||||
2485: "U",
|
||||
2498: "c",
|
||||
2501: "e",
|
||||
2503: "W",
|
||||
2512: "q",
|
||||
2526: "a",
|
||||
2546: "2",
|
||||
2563: "s",
|
||||
2578: "b",
|
||||
2580: "0",
|
||||
2606: "5",
|
||||
2632: "6",
|
||||
2669: "p",
|
||||
2706: "c",
|
||||
2709: "e",
|
||||
2721: "q",
|
||||
2758: "2",
|
||||
2800: "9",
|
||||
2823: "5",
|
||||
2851: "6",
|
||||
3033: "9",
|
||||
3038: "S",
|
||||
3054: "B",
|
||||
3160: "g",
|
||||
3244: "Q",
|
||||
3254: "Q",
|
||||
3266: "G",
|
||||
3291: "S",
|
||||
3308: "B",
|
||||
3414: "8",
|
||||
3423: "g",
|
||||
3514: "Q",
|
||||
3538: "G",
|
||||
3663: "m",
|
||||
3667: "m",
|
||||
3698: "8",
|
||||
3878: "3",
|
||||
3968: "m",
|
||||
4201: "3",
|
||||
};
|
||||
const nums = (p: string) => (p.match(/\d+(?:\.\d*)?/g) ?? []).map(Number);
|
||||
const minY = (p: string) => {
|
||||
const v = nums(p).filter((_, i) => i % 2 === 1);
|
||||
return v.length ? Math.min(...v) : 0;
|
||||
};
|
||||
const moveY = (p: string) => Number(/M(\d+(?:\.\d*)?)\s+(\d+(?:\.\d*)?)/.exec(p)?.[2] ?? 0);
|
||||
const width = (p: string) => {
|
||||
const v = nums(p).filter((_, i) => i % 2 === 0);
|
||||
return v.length ? Math.max(...v) - Math.min(...v) : 0;
|
||||
};
|
||||
function collision(len: number, p: string): string {
|
||||
if (len === 986 || len === 1068) return minY(p) > 13 ? "I" : "l";
|
||||
if (len === 1274 || len === 1380) return moveY(p) > 30 ? "y" : "L";
|
||||
if (len === 1610 || len === 1744) return minY(p) > 19 ? "x" : "J";
|
||||
if (len === 1615) return minY(p) > 18 ? "r" : "N";
|
||||
if (len === 2198 || len === 2381) return minY(p) > 19 ? "n" : "C";
|
||||
if (len === 2318) return width(p) > 30 ? "W" : "4";
|
||||
if (len === 1598 || len === 1731) return minY(p) > 13 ? "X" : "N";
|
||||
if (len === 1694 || len === 1835) return minY(p) > 22 ? "z" : "t";
|
||||
if (len === 2279) return minY(p) > 13 ? "R" : "M";
|
||||
return "";
|
||||
}
|
||||
export function solveSubhdCaptcha(svg: string): string {
|
||||
const paths: [number, string][] = [];
|
||||
for (const m of svg.matchAll(/d=["']([^"']+)["']/g)) {
|
||||
const p = m[1]!;
|
||||
if (p.length <= 500) continue;
|
||||
paths.push([Number(/\d+(?:\.\d*)?/.exec(p)?.[0] ?? 0), p]);
|
||||
}
|
||||
return paths
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([, p]) => collision(p.length, p) || LENGTH_MAP[p.length] || "")
|
||||
.join("");
|
||||
}
|
||||
// 字幕库(云锁)BMP 数字验证码:100×27、5 位、每位 20 像素宽;按 9 个采样点比对 0-9 模板(移植自 MP 插件 captcha/zimuku.py)
|
||||
const W = 100,
|
||||
H = 27,
|
||||
CHAR_W = 20,
|
||||
N = 5,
|
||||
OFFSET = 54;
|
||||
const SAMPLES: [number, number][] = [
|
||||
[10, 7],
|
||||
[7, 8],
|
||||
[12, 8],
|
||||
[10, 13],
|
||||
[7, 19],
|
||||
[12, 19],
|
||||
[10, 20],
|
||||
[6, 13],
|
||||
[14, 13],
|
||||
];
|
||||
const TEMPLATES: Record<string, number[]> = {
|
||||
"0": [1, 1, 1, 1, 1, 1, 1, 1, 0],
|
||||
"1": [0, 1, 0, 0, 0, 0, 1, 0, 0],
|
||||
"2": [1, 0, 1, 0, 1, 0, 1, 0, 0],
|
||||
"3": [1, 0, 1, 1, 0, 1, 1, 0, 0],
|
||||
"4": [0, 0, 1, 0, 0, 1, 0, 0, 0],
|
||||
"5": [1, 1, 0, 0, 0, 1, 1, 0, 0],
|
||||
"6": [1, 0, 1, 1, 1, 1, 1, 1, 0],
|
||||
"7": [1, 0, 1, 0, 0, 0, 0, 0, 0],
|
||||
"8": [1, 1, 1, 1, 1, 1, 1, 0, 0],
|
||||
"9": [1, 1, 1, 0, 1, 0, 1, 0, 0],
|
||||
};
|
||||
export function solveZimukuCaptcha(bmp: Uint8Array): string | null {
|
||||
if (bmp.length < OFFSET || bmp[0] !== 0x42 || bmp[1] !== 0x4d) return null;
|
||||
const dv = new DataView(bmp.buffer, bmp.byteOffset, bmp.byteLength);
|
||||
if (dv.getInt32(18, true) !== W || dv.getInt32(22, true) !== H) return null;
|
||||
const stride = (W * 3 + 3) & ~3;
|
||||
const fg = (x: number, y: number) => {
|
||||
const o = OFFSET + (H - 1 - y) * stride + x * 3;
|
||||
if (o + 2 >= bmp.length || x < 0) return false;
|
||||
return (bmp[o]! + bmp[o + 1]! + bmp[o + 2]!) / 3 < 70;
|
||||
};
|
||||
let out = "",
|
||||
shift = 0;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const f = SAMPLES.map(([px, py]) => (fg(i * CHAR_W + px - shift, py) ? 1 : 0));
|
||||
let best = "?",
|
||||
min = 99;
|
||||
for (const [d, t] of Object.entries(TEMPLATES)) {
|
||||
const diff = t.reduce((n, v, k) => n + (v !== f[k] ? 1 : 0), 0);
|
||||
if (diff < min) {
|
||||
min = diff;
|
||||
best = d;
|
||||
}
|
||||
}
|
||||
if (best === "1") shift += 1;
|
||||
else if (best === "4") shift -= 1;
|
||||
out += best;
|
||||
}
|
||||
return out.includes("?") ? null : out;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// 起不来(权限 / 运行时不支持)时退回进程内执行并记日志:仍有超时,但卡死的同步代码无法收回(只在本机调试时出现)。
|
||||
import { basename } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { SANDBOX_JOBS, type SandboxInput, type SandboxJobName, type SandboxOutput } from "./jobs.ts";
|
||||
import { prepareJobs, SANDBOX_JOBS, type SandboxInput, type SandboxJobName, type SandboxOutput } from "./jobs.ts";
|
||||
|
||||
export type SandboxErrorKind = "timeout" | "memory" | "crash" | "job" | "spawn";
|
||||
|
||||
@@ -144,6 +144,7 @@ async function runInProcess<J extends SandboxJobName>(
|
||||
input: SandboxInput<J>,
|
||||
timeoutMs: number,
|
||||
): Promise<unknown> {
|
||||
await prepareJobs();
|
||||
const fn = SANDBOX_JOBS[job] as (i: SandboxInput<J>) => unknown;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
// 输入输出必须能 JSON 化(二进制用 base64)。新增任务:在这里加一项,调用方用 runSandbox(名字, 输入)。
|
||||
import { parseTorrent } from "@umt/adapters";
|
||||
import { decodeSubtitle, detectSubtitleLanguage, subtitleEpisode, subtitleExtOf } from "@umt/domain";
|
||||
import { unzipSync } from "fflate";
|
||||
import { buildPreview } from "../modules/file-manager/preview-build.ts";
|
||||
import { PAGE_PARSERS, type PageKind } from "../modules/subtitles/online/parse.ts";
|
||||
import { archiveKindOf, extractArchive, prepareArchives } from "./archives.ts";
|
||||
import { solveSubhdCaptcha, solveZimukuCaptcha } from "./captcha.ts";
|
||||
|
||||
const MAX_SUBTITLE_BYTES = 10 * 1024 * 1024;
|
||||
const MAX_ARCHIVE_ENTRIES = 300;
|
||||
@@ -30,8 +32,13 @@ export interface ParsedSubtitleFile {
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** 字幕 / zip:解压、解码转 UTF-8、识别语言与季集 */
|
||||
function subtitleParse(input: { files: { name: string; base64: string }[] }) {
|
||||
/** 子进程撤销权限前调用:预热要读文件的 WASM 库 */
|
||||
export async function prepareJobs(): Promise<void> {
|
||||
await prepareArchives();
|
||||
}
|
||||
|
||||
/** 字幕 / zip / rar / 7z:解压、解码转 UTF-8、识别语言与季集 */
|
||||
async function subtitleParse(input: { files: { name: string; base64: string }[] }) {
|
||||
const items: ParsedSubtitleFile[] = [];
|
||||
const skipped: { name: string; reason: string }[] = [];
|
||||
const one = (name: string, bytes: Uint8Array, archive: string | null) => {
|
||||
@@ -61,17 +68,16 @@ function subtitleParse(input: { files: { name: string; base64: string }[] }) {
|
||||
skipped.push({ name: f.name, reason: "内容不是有效的 base64" });
|
||||
continue;
|
||||
}
|
||||
if (/\.zip$/i.test(f.name)) {
|
||||
const kind = archiveKindOf(f.name);
|
||||
if (kind) {
|
||||
let entries: Record<string, Uint8Array>;
|
||||
try {
|
||||
let n = 0;
|
||||
entries = unzipSync(bytes, {
|
||||
filter: (e) =>
|
||||
!e.name.endsWith("/") &&
|
||||
!!subtitleExtOf(e.name) &&
|
||||
e.originalSize <= MAX_SUBTITLE_BYTES &&
|
||||
++n <= MAX_ARCHIVE_ENTRIES,
|
||||
});
|
||||
entries = await extractArchive(
|
||||
kind,
|
||||
bytes,
|
||||
(name, size) => !!subtitleExtOf(name) && size <= MAX_SUBTITLE_BYTES && ++n <= MAX_ARCHIVE_ENTRIES,
|
||||
);
|
||||
} catch {
|
||||
skipped.push({ name: f.name, reason: "压缩包损坏或加密" });
|
||||
continue;
|
||||
@@ -79,8 +85,6 @@ function subtitleParse(input: { files: { name: string; base64: string }[] }) {
|
||||
const names = Object.keys(entries);
|
||||
if (!names.length) skipped.push({ name: f.name, reason: "压缩包里没有字幕文件" });
|
||||
for (const n of names) one(n, entries[n]!, f.name);
|
||||
} else if (/\.(rar|7z)$/i.test(f.name)) {
|
||||
skipped.push({ name: f.name, reason: "暂不支持 rar / 7z,请解压后上传或改用 zip" });
|
||||
} else one(f.name, bytes, null);
|
||||
}
|
||||
return { items, skipped };
|
||||
@@ -103,10 +107,26 @@ function selfTest(input: { mode: "ok" | "hang" | "memory" | "throw" | "io" }) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** SubHD SVG / 字幕库 BMP 验证码 */
|
||||
function captchaSolve(input: { kind: "subhd"; svg: string } | { kind: "zimuku"; bmpBase64: string }) {
|
||||
return {
|
||||
code: input.kind === "subhd" ? solveSubhdCaptcha(input.svg) : solveZimukuCaptcha(b64decode(input.bmpBase64)),
|
||||
};
|
||||
}
|
||||
|
||||
/** 字幕站页面 → 结构化数据(网页同样不可信) */
|
||||
function subtitlePage(input: { kind: PageKind; html: string }) {
|
||||
const fn = PAGE_PARSERS[input.kind] as (html: string) => unknown;
|
||||
if (!fn) throw new Error(`没有这种页面解析:${input.kind}`);
|
||||
return fn(input.html);
|
||||
}
|
||||
|
||||
export const SANDBOX_JOBS = {
|
||||
"subtitle.parse": subtitleParse,
|
||||
"torrent.parse": torrentParse,
|
||||
"file.preview": buildPreview,
|
||||
"captcha.solve": captchaSolve,
|
||||
"subtitle.page": subtitlePage,
|
||||
"self.test": selfTest,
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 沙箱子进程入口:先加载任务表,再撤销全部权限,然后从 stdin 读一条 {job, input},把 {ok, result | error} 写到 stdout。
|
||||
// 开发 / 测试由 deno run 直接跑本文件;编译二进制以 `<自身> --role sandbox` 拉起(main.ts 在加载其余模块前分派)。
|
||||
import { SANDBOX_JOBS } from "./jobs.ts";
|
||||
import { prepareJobs, SANDBOX_JOBS } from "./jobs.ts";
|
||||
|
||||
const PERMISSIONS = ["read", "write", "net", "env", "run", "ffi", "sys"] as const;
|
||||
|
||||
@@ -10,6 +10,7 @@ async function writeAll(bytes: Uint8Array) {
|
||||
}
|
||||
|
||||
export async function sandboxWorkerMain(): Promise<void> {
|
||||
await prepareJobs();
|
||||
for (const name of PERMISSIONS) {
|
||||
try {
|
||||
Deno.permissions.revokeSync({ name } as Deno.PermissionDescriptor);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildFakeTorrent } from "@umt/testkit";
|
||||
import { buildFakeTorrent, RAR5_SAMPLE_BASE64, SEVENZIP_SUBTITLE_BASE64, ZIMUKU_CAPTCHA } from "@umt/testkit";
|
||||
import { zipSync } from "fflate";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runSandbox, SandboxError } from "../../src/sandbox/client.ts";
|
||||
@@ -48,4 +48,36 @@ describe("沙箱子进程", () => {
|
||||
expect(f.files.map((x) => x.path)).toEqual(["Show.S01E01.mkv"]);
|
||||
expect(f.hash).toBe(t.infoHash);
|
||||
});
|
||||
|
||||
it("rar / 7z 在沙箱里解压(撤销权限后 WASM 仍可用);非字幕条目跳过", async () => {
|
||||
const r = await runSandbox("subtitle.parse", {
|
||||
files: [
|
||||
{ name: "pack.7z", base64: SEVENZIP_SUBTITLE_BASE64 },
|
||||
{ name: "texts.rar", base64: RAR5_SAMPLE_BASE64 },
|
||||
],
|
||||
});
|
||||
expect(r.items).toEqual([
|
||||
expect.objectContaining({ name: "s7/a.chs.srt", archive: "pack.7z", language: "zh-Hans" }),
|
||||
]);
|
||||
expect(r.skipped).toEqual([{ name: "texts.rar", reason: "压缩包里没有字幕文件" }]);
|
||||
});
|
||||
|
||||
it("字幕库验证码:真实样本识别正确", async () => {
|
||||
expect(await runSandbox("captcha.solve", { kind: "zimuku", bmpBase64: ZIMUKU_CAPTCHA.bmpBase64 })).toEqual({
|
||||
code: ZIMUKU_CAPTCHA.code,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("压缩包解压(进程内,直接测解压模块)", () => {
|
||||
it("rar 列出全部条目", async () => {
|
||||
const { extractArchive } = await import("../../src/sandbox/archives.ts");
|
||||
const out = await extractArchive(
|
||||
"rar",
|
||||
Uint8Array.from(atob(RAR5_SAMPLE_BASE64), (c) => c.charCodeAt(0)),
|
||||
() => true,
|
||||
);
|
||||
expect(Object.keys(out).sort()).toEqual(["stest1.txt", "stest2.txt"]);
|
||||
await expect(extractArchive("rar", new Uint8Array([1, 2, 3]), () => true)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -129,7 +129,15 @@ describe.skipIf(!up)("站点类型:API、调度与 RSS", () => {
|
||||
.map((d) => d.key)
|
||||
.sort(),
|
||||
).toEqual([...pub].sort());
|
||||
expect(items.filter((d) => !pub.includes(d.key)).every((d) => d.kind === "pt")).toBe(true);
|
||||
// 内置字幕站(SubHD、字幕库)也在定义列表里,类型 subtitle
|
||||
const subs = ["subhd", "zimuku"];
|
||||
expect(
|
||||
items
|
||||
.filter((d) => d.kind === "subtitle")
|
||||
.map((d) => d.key)
|
||||
.sort(),
|
||||
).toEqual(subs);
|
||||
expect(items.filter((d) => !pub.includes(d.key) && !subs.includes(d.key)).every((d) => d.kind === "pt")).toBe(true);
|
||||
const nyaa = await json(
|
||||
await h.req("/sites", { body: { key: "nyaa", name: "Nyaa", adapter: "api", baseUrl: "https://nyaa.si/" } }),
|
||||
);
|
||||
@@ -203,4 +211,23 @@ describe.skipIf(!up)("站点类型:API、调度与 RSS", () => {
|
||||
reason: "unsupported",
|
||||
});
|
||||
});
|
||||
|
||||
it("字幕站:只能从内置字幕站添加;不排快照、不进搜索、能力为空", async () => {
|
||||
const ok = await json(
|
||||
await h.req("/sites", {
|
||||
body: { kind: "subtitle", key: "zimuku", name: "字幕库", adapter: "subtitle", baseUrl: "https://zmk.pw/" },
|
||||
}),
|
||||
);
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body.data).toMatchObject({
|
||||
kind: "subtitle",
|
||||
snapshotEnabled: false,
|
||||
searchDefault: false,
|
||||
capabilities: [],
|
||||
});
|
||||
const bad = await h.req("/sites", {
|
||||
body: { kind: "subtitle", key: "random", name: "x", adapter: "subtitle", baseUrl: "https://x.example/" },
|
||||
});
|
||||
expect(bad.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
SUBHD_DETAIL_PAGE,
|
||||
SUBHD_SUB_PAGE,
|
||||
ZIMUKU_CAPTCHA,
|
||||
ZIMUKU_CAPTCHA_PAGE,
|
||||
ZIMUKU_DETAIL_PAGE,
|
||||
ZIMUKU_DL_PAGE,
|
||||
ZIMUKU_SEARCH_PAGE,
|
||||
ZIMUKU_SUBS_PAGE,
|
||||
} from "@umt/testkit";
|
||||
import { zipSync } from "fflate";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { downloadOnline, searchOnline } from "../../src/modules/subtitles/online/index.ts";
|
||||
import { createHarness, type Harness, pgAvailable } from "../sources-identity/harness.ts";
|
||||
|
||||
// 在线字幕(docs/plan/subtitles.md 第 2 步):站点请求全部走注入的 fetch,页面是 2026-10-03 的真实截取;
|
||||
// 字幕库先回验证码页,沙箱识别后带 security_verify_img 提交,再开搜索页;SubHD 走 prepare-download → down → api/sub/down
|
||||
const pg = await pgAvailable();
|
||||
const enc = (s: string) => new TextEncoder().encode(s);
|
||||
|
||||
describe.skipIf(!pg)("在线字幕:SubHD / 字幕库(注入 fetch)", () => {
|
||||
let h: Harness;
|
||||
const requests: string[] = [];
|
||||
let verified = false;
|
||||
const siteIds: Record<string, string> = {};
|
||||
const html = (body: string, status = 200) =>
|
||||
new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8" } });
|
||||
const zip = zipSync({
|
||||
"The.Shawshank.Redemption.1994.chs&eng.ass": enc(
|
||||
"[Script Info]\nDialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,你好 Hello",
|
||||
),
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createHarness("subonline", {
|
||||
fetch: async (input, init) => {
|
||||
const u = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
|
||||
const method = init?.method ?? "GET";
|
||||
requests.push(`${method} ${u.host}${u.pathname}${u.search}`);
|
||||
if (u.host === "zmk.pw") {
|
||||
if (u.searchParams.has("security_verify_img")) {
|
||||
const want = [...ZIMUKU_CAPTCHA.code].map((c) => c.charCodeAt(0).toString(16)).join("");
|
||||
verified = u.searchParams.get("security_verify_img") === want;
|
||||
return html("ok", 302);
|
||||
}
|
||||
if (!verified) return html(ZIMUKU_CAPTCHA_PAGE, 404);
|
||||
if (u.pathname === "/search") return html(ZIMUKU_SEARCH_PAGE);
|
||||
if (/^\/subs\/\d+\.html$/.test(u.pathname)) return html(ZIMUKU_SUBS_PAGE);
|
||||
if (u.pathname === "/detail/203404.html") return html(ZIMUKU_DETAIL_PAGE);
|
||||
if (u.pathname === "/dld/203404.html") return html(ZIMUKU_DL_PAGE);
|
||||
if (u.pathname.startsWith("/download/"))
|
||||
return new Response(zip, {
|
||||
headers: {
|
||||
"content-type": "application/octet-stream",
|
||||
"content-disposition": 'attachment; filename="sub.zip"',
|
||||
},
|
||||
});
|
||||
}
|
||||
if (u.host === "subhd.tv") {
|
||||
if (u.pathname === "/d/1292052") return html(SUBHD_DETAIL_PAGE);
|
||||
if (u.pathname.startsWith("/a/")) return html(SUBHD_SUB_PAGE);
|
||||
if (u.pathname === "/api/sub/prepare-download") return Response.json({ success: true, url: "/down/Fp90EG" });
|
||||
if (u.pathname === "/down/Fp90EG") return html("<html></html>");
|
||||
if (u.pathname === "/api/sub/down")
|
||||
return Response.json({ success: true, pass: true, url: "https://dl.subhd.example/x/1.srt" });
|
||||
}
|
||||
if (u.host === "dl.subhd.example")
|
||||
return new Response(enc("1\n00:00:01,000 --> 00:00:02,000\n你好\n"), {
|
||||
headers: { "content-disposition": 'attachment; filename="Shawshank.chs.srt"' },
|
||||
});
|
||||
return html("not found", 404);
|
||||
},
|
||||
});
|
||||
for (const [key, name, url] of [
|
||||
["subhd", "SubHD", "https://subhd.tv/"],
|
||||
["zimuku", "字幕库", "https://zmk.pw/"],
|
||||
] as const) {
|
||||
const r = await h.db
|
||||
.insertInto("site")
|
||||
.values({ key, name, kind: "subtitle", adapter: "subtitle", base_url: url } as never)
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
siteIds[key] = r.id;
|
||||
}
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
// 字幕库样例是《葬送的芙莉莲》的搜索结果;SubHD 按豆瓣 ID 开条目页,不按标题过滤
|
||||
const q = { keywords: ["葬送的芙莉莲"], doubanId: "1292052", season: null, episode: null };
|
||||
|
||||
it("搜索:SubHD 有豆瓣 ID 直接开条目页;字幕库先过验证码再搜;结果按语言与下载量排序", async () => {
|
||||
const r = await searchOnline(h.ctx, h.rt, q);
|
||||
expect(r.errors).toEqual([]);
|
||||
expect(r.noSites).toBe(false);
|
||||
expect(verified).toBe(true);
|
||||
expect(requests).toContain("GET subhd.tv/d/1292052");
|
||||
const zim = r.results.filter((x) => x.siteKey === "zimuku");
|
||||
const sub = r.results.filter((x) => x.siteKey === "subhd");
|
||||
expect(zim.length).toBeGreaterThan(0);
|
||||
expect(sub.length).toBeGreaterThan(0);
|
||||
// 双语排在前面
|
||||
expect(r.results[0]!.languages).toContain("双语");
|
||||
expect(zim.find((x) => x.ref === "/detail/203404.html")).toMatchObject({ siteName: "字幕库", downloads: 17000 });
|
||||
});
|
||||
|
||||
it("下载:字幕库走下载页取文件;SubHD 走 prepare-download → api/sub/down", async () => {
|
||||
const z = await downloadOnline(h.ctx, h.rt, siteIds.zimuku!, "/detail/203404.html");
|
||||
expect(z).toMatchObject({ name: "sub.zip", siteKey: "zimuku", pageUrl: "https://zmk.pw/detail/203404.html" });
|
||||
const s = await downloadOnline(h.ctx, h.rt, siteIds.subhd!, "/a/Fp90EG");
|
||||
expect(s.name).toBe("Shawshank.chs.srt");
|
||||
expect(requests).toContain("POST subhd.tv/api/sub/prepare-download");
|
||||
});
|
||||
|
||||
it("字幕站停用:不搜;下载报错", async () => {
|
||||
await h.db.updateTable("site").set({ enabled: false }).where("kind", "=", "subtitle").execute();
|
||||
const r = await searchOnline(h.ctx, h.rt, q);
|
||||
expect(r.noSites).toBe(true);
|
||||
await expect(downloadOnline(h.ctx, h.rt, siteIds.subhd!, "/a/x")).rejects.toThrow(/已停用/);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,10 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
type SubtitleApplyRequest,
|
||||
SubtitleApplyResult,
|
||||
type SubtitleFetchRequest,
|
||||
SubtitleParseResponse,
|
||||
type SubtitleSearchRequest,
|
||||
SubtitleSearchResponse,
|
||||
SubtitleTargetsResponse,
|
||||
SubtitleWritesResponse,
|
||||
SubtitleWriteView,
|
||||
@@ -82,3 +85,18 @@ export function useUndoSubtitle(entityId: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSearchSubtitles() {
|
||||
return useMutation({
|
||||
mutationFn: async (body: SubtitleSearchRequest) =>
|
||||
parseOrThrow(SubtitleSearchResponse, await api.post<unknown>("/subtitles/search", body), "/subtitles/search"),
|
||||
});
|
||||
}
|
||||
|
||||
/** 下载一条在线字幕并解析(同上传的预览,不写文件) */
|
||||
export function useFetchSubtitle() {
|
||||
return useMutation({
|
||||
mutationFn: async (body: SubtitleFetchRequest) =>
|
||||
parseOrThrow(SubtitleParseResponse, await api.post<unknown>("/subtitles/fetch", body), "/subtitles/fetch"),
|
||||
});
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type SiteListItem,
|
||||
type SiteLoginResult,
|
||||
} from "@umt/contracts";
|
||||
import { ArrowLeft, Check, Globe, LoaderCircle, Rss, Search, Server, ShieldCheck } from "lucide-react";
|
||||
import { ArrowLeft, Captions, Check, Globe, LoaderCircle, Rss, Search, Server, ShieldCheck } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
@@ -48,8 +48,8 @@ const PT_STEPS = ["选择类型", "选择站点", "填写凭证", "测试"] as c
|
||||
const FEED_STEPS = ["选择类型", "选择站点"] as const;
|
||||
const GROUP_ORDER = ["nexusphp", "mteam_api", "api", "generic_rss"] as const;
|
||||
/** 自定义 RSS 源与索引器入口暂时不开放(已有的 RSS 源站点照常显示) */
|
||||
const KIND_ORDER: SiteKind[] = ["pt", "public_bt"];
|
||||
const KIND_ICON = { pt: ShieldCheck, public_bt: Globe, rss_feed: Rss, torznab: Server } as const;
|
||||
const KIND_ORDER: SiteKind[] = ["pt", "public_bt", "subtitle"];
|
||||
const KIND_ICON = { pt: ShieldCheck, public_bt: Globe, rss_feed: Rss, torznab: Server, subtitle: Captions } as const;
|
||||
|
||||
/** 馒头默认 API Key,其余默认 Cookie(验证码站点账密大概率失败) */
|
||||
function defaultKind(kinds: LoginKind[]): LoginKind {
|
||||
@@ -105,13 +105,14 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i
|
||||
})).filter((g) => g.items.length);
|
||||
}, [items, q]);
|
||||
|
||||
const publicDefs = items.filter((d) => d.kind === "public_bt");
|
||||
// 公开 BT 站 / 字幕站:都是内置站,一键添加
|
||||
const publicDefs = items.filter((d) => d.kind === kind);
|
||||
/** 内置公开 BT 站:一步建好(专用适配器、定时抓最新),进站点页 */
|
||||
const addPublic = async (d: SiteDefinitionView) => {
|
||||
try {
|
||||
setPhase("保存中…");
|
||||
const site = await create.mutateAsync({
|
||||
kind: "public_bt",
|
||||
kind: d.kind,
|
||||
key: d.key,
|
||||
adapter: d.adapter,
|
||||
name: d.name,
|
||||
@@ -187,7 +188,9 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i
|
||||
<LoadingState rows={2} />
|
||||
) : null}
|
||||
<p className="mt-3 text-meta text-muted-foreground">
|
||||
不需要账号;添加后默认走代理,定时抓取最新资源,也能在资源搜索里选用。
|
||||
{kind === "subtitle"
|
||||
? "不需要账号;在作品页「补字幕」里搜索、下载。"
|
||||
: "不需要账号;添加后默认走代理,定时抓取最新资源,也能在资源搜索里选用。"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -336,7 +336,46 @@ function PublicSiteCard({ site, onDialog }: Omit<Props, "checks">) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 字幕站(SubHD、字幕库):出口与状态;搜索 / 下载在作品页「补字幕」里 */
|
||||
function SubtitleSiteCard({ site, checks, onDialog }: Props) {
|
||||
const health = healthLine(site.lastHealth, site.lastHealthAt);
|
||||
const busy = checks.isBusy(site.id, "health");
|
||||
return (
|
||||
<article className={cn("pg-slab flex min-w-0 flex-col rounded-xl p-4", !site.enabled && "opacity-75")}>
|
||||
<SiteCardHeader site={site} />
|
||||
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 rounded-lg border border-border/60 px-3 py-2.5 text-caption">
|
||||
<div className="min-w-0">
|
||||
<dt className="text-meta text-muted-foreground">出口</dt>
|
||||
<dd className="text-foreground">{site.proxy ? "代理" : "直连"}</dd>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<dt className="text-meta text-muted-foreground">状态</dt>
|
||||
<dd className="truncate" title={healthMessage(site.lastHealth) ?? undefined}>
|
||||
<StatusDot tone={health.tone} />
|
||||
{health.text}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="mt-2 text-meta text-muted-foreground">在作品页「补字幕」里搜索、下载。</p>
|
||||
<div aria-hidden className="min-h-4 flex-1" />
|
||||
<footer className="mt-auto flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy || !site.enabled}
|
||||
onClick={() => void checks.checkHealth(site)}
|
||||
>
|
||||
{busy ? <LoaderCircle className="animate-spin" /> : <Activity />}
|
||||
检测健康
|
||||
</Button>
|
||||
<SiteMoreMenu site={site} onDialog={onDialog} />
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteCard({ site, checks, onDialog }: Props) {
|
||||
if (site.kind === "subtitle") return <SubtitleSiteCard site={site} checks={checks} onDialog={onDialog} />;
|
||||
if (!hasLogin(site) && siteCan(site, "list")) return <PublicSiteCard site={site} onDialog={onDialog} />;
|
||||
if (!hasLogin(site)) return <FeedSiteCard site={site} onDialog={onDialog} />;
|
||||
return <AccountSiteCard site={site} checks={checks} onDialog={onDialog} />;
|
||||
@@ -480,8 +519,8 @@ export function SiteTable({
|
||||
sites: SiteListItem[];
|
||||
checks: Checks;
|
||||
onDialog: (d: SiteDialog) => void;
|
||||
/** pt = 带账号与流量列;public = 公开站(无账号),只列状态与资源更新 */
|
||||
variant?: "pt" | "public";
|
||||
/** pt = 带账号与流量列;public = 公开站(无账号),只列状态与资源更新;subtitle = 字幕站,只列出口与状态 */
|
||||
variant?: "pt" | "public" | "subtitle";
|
||||
}) {
|
||||
const [params] = useSearchParams();
|
||||
const name: DataColumn<SiteListItem> = {
|
||||
@@ -530,8 +569,22 @@ export function SiteTable({
|
||||
</div>
|
||||
),
|
||||
};
|
||||
const health: DataColumn<SiteListItem> = {
|
||||
key: "health",
|
||||
header: "状态",
|
||||
hideBelow: "sm",
|
||||
cell: (s) => {
|
||||
const h = healthLine(s.lastHealth, s.lastHealthAt);
|
||||
return (
|
||||
<span className="whitespace-nowrap text-caption" title={healthMessage(s.lastHealth) ?? h.text}>
|
||||
<StatusDot tone={h.tone} />
|
||||
{h.text}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
};
|
||||
const columns: DataColumn<SiteListItem>[] =
|
||||
variant === "public"
|
||||
variant === "subtitle"
|
||||
? [
|
||||
name,
|
||||
{
|
||||
@@ -540,94 +593,108 @@ export function SiteTable({
|
||||
hideBelow: "sm",
|
||||
cell: (s) => <span className="whitespace-nowrap text-muted-foreground">{s.proxy ? "代理" : "直连"}</span>,
|
||||
},
|
||||
{
|
||||
key: "search",
|
||||
header: "资源搜索",
|
||||
hideBelow: "sm",
|
||||
cell: (s) => (
|
||||
<span className="whitespace-nowrap text-muted-foreground">{s.searchDefault ? "默认参与" : "手动选"}</span>
|
||||
),
|
||||
},
|
||||
snapshot,
|
||||
health,
|
||||
actions,
|
||||
]
|
||||
: [
|
||||
name,
|
||||
{
|
||||
key: "account",
|
||||
header: "账号",
|
||||
hideBelow: "xl",
|
||||
cell: (s) => (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<AccountBadges site={s} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "login",
|
||||
header: "上次登录",
|
||||
hideBelow: "xl",
|
||||
cell: (s) => (
|
||||
<span className="whitespace-nowrap text-muted-foreground">
|
||||
{relTime(primaryAccount(s.accounts)?.lastLoginAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "traffic",
|
||||
header: "上传 / 下载",
|
||||
hideBelow: "md",
|
||||
cell: (s) => {
|
||||
const u = dataAccount(s.accounts)?.userData;
|
||||
return u ? (
|
||||
<span className="whitespace-nowrap tabular-nums">
|
||||
{bytes(u.upload)} / {bytes(u.download)}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
);
|
||||
: variant === "public"
|
||||
? [
|
||||
name,
|
||||
{
|
||||
key: "proxy",
|
||||
header: "出口",
|
||||
hideBelow: "sm",
|
||||
cell: (s) => <span className="whitespace-nowrap text-muted-foreground">{s.proxy ? "代理" : "直连"}</span>,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "ratio",
|
||||
header: "分享率",
|
||||
align: "right",
|
||||
cell: (s) => (
|
||||
<span className="whitespace-nowrap tabular-nums">{ratioText(dataAccount(s.accounts)?.userData)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "level",
|
||||
header: "等级 · 做种",
|
||||
hideBelow: "lg",
|
||||
cell: (s) => {
|
||||
const u = dataAccount(s.accounts)?.userData;
|
||||
const t = u ? `${u.userLevel ?? "—"} · ${u.seeding}` : "—";
|
||||
return (
|
||||
<span className="block max-w-40 truncate whitespace-nowrap" title={t}>
|
||||
{t}
|
||||
{
|
||||
key: "search",
|
||||
header: "资源搜索",
|
||||
hideBelow: "sm",
|
||||
cell: (s) => (
|
||||
<span className="whitespace-nowrap text-muted-foreground">
|
||||
{s.searchDefault ? "默认参与" : "手动选"}
|
||||
</span>
|
||||
);
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "health",
|
||||
header: "状态",
|
||||
hideBelow: "md",
|
||||
cell: (s) => {
|
||||
if (!hasLogin(s)) return <span className="text-muted-foreground">—</span>;
|
||||
const h = healthLine(s.lastHealth, s.lastHealthAt);
|
||||
return (
|
||||
<span className="whitespace-nowrap text-caption" title={healthMessage(s.lastHealth) ?? h.text}>
|
||||
<StatusDot tone={h.tone} />
|
||||
{h.text}
|
||||
snapshot,
|
||||
actions,
|
||||
]
|
||||
: [
|
||||
name,
|
||||
{
|
||||
key: "account",
|
||||
header: "账号",
|
||||
hideBelow: "xl",
|
||||
cell: (s) => (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<AccountBadges site={s} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "login",
|
||||
header: "上次登录",
|
||||
hideBelow: "xl",
|
||||
cell: (s) => (
|
||||
<span className="whitespace-nowrap text-muted-foreground">
|
||||
{relTime(primaryAccount(s.accounts)?.lastLoginAt)}
|
||||
</span>
|
||||
);
|
||||
),
|
||||
},
|
||||
},
|
||||
snapshot,
|
||||
actions,
|
||||
];
|
||||
{
|
||||
key: "traffic",
|
||||
header: "上传 / 下载",
|
||||
hideBelow: "md",
|
||||
cell: (s) => {
|
||||
const u = dataAccount(s.accounts)?.userData;
|
||||
return u ? (
|
||||
<span className="whitespace-nowrap tabular-nums">
|
||||
{bytes(u.upload)} / {bytes(u.download)}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "ratio",
|
||||
header: "分享率",
|
||||
align: "right",
|
||||
cell: (s) => (
|
||||
<span className="whitespace-nowrap tabular-nums">{ratioText(dataAccount(s.accounts)?.userData)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "level",
|
||||
header: "等级 · 做种",
|
||||
hideBelow: "lg",
|
||||
cell: (s) => {
|
||||
const u = dataAccount(s.accounts)?.userData;
|
||||
const t = u ? `${u.userLevel ?? "—"} · ${u.seeding}` : "—";
|
||||
return (
|
||||
<span className="block max-w-40 truncate whitespace-nowrap" title={t}>
|
||||
{t}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "health",
|
||||
header: "状态",
|
||||
hideBelow: "md",
|
||||
cell: (s) => {
|
||||
if (!hasLogin(s)) return <span className="text-muted-foreground">—</span>;
|
||||
const h = healthLine(s.lastHealth, s.lastHealthAt);
|
||||
return (
|
||||
<span className="whitespace-nowrap text-caption" title={healthMessage(s.lastHealth) ?? h.text}>
|
||||
<StatusDot tone={h.tone} />
|
||||
{h.text}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
snapshot,
|
||||
actions,
|
||||
];
|
||||
// 站点数量不多:分组表格不分页
|
||||
return (
|
||||
<DataTable
|
||||
|
||||
@@ -24,6 +24,7 @@ export const ADAPTER_LABEL: Record<SiteAdapter, string> = {
|
||||
mteam_api: "M-Team API",
|
||||
api: "站点 API",
|
||||
generic_rss: "通用 RSS",
|
||||
subtitle: "字幕站",
|
||||
};
|
||||
|
||||
/** 站点类型名(IA-2);旧数据缺 kind 时按 PT 站 */
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import type { SubtitleLanguage, SubtitleParsedItem, SubtitleTarget, SubtitleWriteView } from "@umt/contracts";
|
||||
import { FileUp, LoaderCircle, Undo2 } from "lucide-react";
|
||||
import type {
|
||||
SubtitleLanguage,
|
||||
SubtitleParsedItem,
|
||||
SubtitleSearchResult,
|
||||
SubtitleTarget,
|
||||
SubtitleWriteView,
|
||||
} from "@umt/contracts";
|
||||
import { Download, ExternalLink, FileUp, LoaderCircle, Search, Undo2 } from "lucide-react";
|
||||
import { useId, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
useApplySubtitles,
|
||||
useFetchSubtitle,
|
||||
useParseSubtitles,
|
||||
useSearchSubtitles,
|
||||
useSubtitleTargets,
|
||||
useSubtitleWrites,
|
||||
useUndoSubtitle,
|
||||
@@ -20,7 +28,9 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
@@ -74,6 +84,7 @@ export function SubtitleDialog({
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [skipped, setSkipped] = useState<{ name: string; reason: string }[]>([]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [tab, setTab] = useState<"upload" | "online">("upload");
|
||||
const list = targets.data?.targets ?? [];
|
||||
const busy = parse.isPending || apply.isPending;
|
||||
|
||||
@@ -112,6 +123,8 @@ export function SubtitleDialog({
|
||||
language: x.language,
|
||||
content: x.content,
|
||||
targetLocationId: x.target,
|
||||
...(x.source ? { source: x.source } : {}),
|
||||
...(x.sourceRef ? { sourceRef: x.sourceRef } : {}),
|
||||
})),
|
||||
});
|
||||
if (r.written.length) toast.success(`已写入 ${r.written.length} 个字幕`);
|
||||
@@ -141,41 +154,58 @@ export function SubtitleDialog({
|
||||
<EmptyState title="媒体库里还没有这部作品的视频" description="整理入库后才能补字幕。" />
|
||||
) : (
|
||||
<div className="min-w-0 space-y-4">
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
void load([...e.dataTransfer.files]);
|
||||
}}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col items-center gap-1 rounded-lg border border-dashed border-border px-4 py-6 text-center transition-colors hover:border-foreground/40",
|
||||
dragging && "border-foreground/60 bg-accent",
|
||||
)}
|
||||
>
|
||||
{parse.isPending ? <LoaderCircle className="size-5 animate-spin" /> : <FileUp className="size-5" />}
|
||||
<span className="text-caption text-foreground">选择或拖入字幕文件</span>
|
||||
<span className="text-meta text-muted-foreground">
|
||||
支持 srt / ass / ssa / vtt / sub / sbv 和 zip,可一次选多个
|
||||
</span>
|
||||
<input
|
||||
ref={input}
|
||||
id={inputId}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPT}
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
void load([...(e.target.files ?? [])]);
|
||||
if (input.current) input.current.value = "";
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as "upload" | "online")}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="upload">上传</TabsTrigger>
|
||||
<TabsTrigger value="online">在线搜索</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{tab === "upload" ? (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
void load([...e.dataTransfer.files]);
|
||||
}}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col items-center gap-1 rounded-lg border border-dashed border-border px-4 py-6 text-center transition-colors hover:border-foreground/40",
|
||||
dragging && "border-foreground/60 bg-accent",
|
||||
)}
|
||||
>
|
||||
{parse.isPending ? <LoaderCircle className="size-5 animate-spin" /> : <FileUp className="size-5" />}
|
||||
<span className="text-caption text-foreground">选择或拖入字幕文件</span>
|
||||
<span className="text-meta text-muted-foreground">
|
||||
支持 srt / ass / ssa / vtt / sub / sbv 和 zip,可一次选多个
|
||||
</span>
|
||||
<input
|
||||
ref={input}
|
||||
id={inputId}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPT}
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
void load([...(e.target.files ?? [])]);
|
||||
if (input.current) input.current.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<OnlinePanel
|
||||
entityId={entityId}
|
||||
seasons={[...new Set(list.map((t) => t.season).filter((x): x is number => x !== null))]}
|
||||
onFetched={(items, skip) => {
|
||||
setRows((prev) => [...prev, ...items.map((it) => ({ ...it, target: it.targetLocationId ?? SKIP }))]);
|
||||
setSkipped((prev) => [...prev, ...skip]);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{rows.length ? (
|
||||
<ul className="space-y-2">
|
||||
@@ -318,3 +348,145 @@ function History({
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 在线搜索:已启用的字幕站(SubHD、字幕库);下载的字幕进上面同一个预览 */
|
||||
function OnlinePanel({
|
||||
entityId,
|
||||
seasons,
|
||||
onFetched,
|
||||
}: {
|
||||
entityId: string;
|
||||
seasons: number[];
|
||||
onFetched: (items: SubtitleParsedItem[], skipped: { name: string; reason: string }[]) => void;
|
||||
}) {
|
||||
const search = useSearchSubtitles();
|
||||
const fetch = useFetchSubtitle();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [season, setSeason] = useState<string>(seasons.length === 1 ? String(seasons[0]) : "all");
|
||||
const [episode, setEpisode] = useState("");
|
||||
const [fetching, setFetching] = useState<string | null>(null);
|
||||
const data = search.data;
|
||||
|
||||
const run = () =>
|
||||
search.mutate(
|
||||
{
|
||||
entityId,
|
||||
...(keyword.trim() ? { keyword: keyword.trim() } : {}),
|
||||
...(season !== "all" ? { season: Number(season) } : {}),
|
||||
...(episode.trim() && /^\d+$/.test(episode.trim()) ? { episode: Number(episode.trim()) } : {}),
|
||||
},
|
||||
{ onError: (err) => toast.error(errorMessage(err, "搜索失败")) },
|
||||
);
|
||||
const take = async (r: SubtitleSearchResult) => {
|
||||
setFetching(`${r.siteId}${r.ref}`);
|
||||
try {
|
||||
const p = await fetch.mutateAsync({ entityId, siteId: r.siteId, ref: r.ref, title: r.title });
|
||||
if (!p.items.length) toast.error("这个字幕包里没有能用的字幕文件");
|
||||
else toast.success(`已下载 ${p.items.length} 个字幕,检查下面的配对后写入`);
|
||||
onFetched(p.items, p.skipped);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "下载失败"));
|
||||
} finally {
|
||||
setFetching(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-3">
|
||||
<form
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
run();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="默认按作品名搜,可另填关键词"
|
||||
aria-label="关键词"
|
||||
className="min-w-48 flex-1"
|
||||
/>
|
||||
{seasons.length ? (
|
||||
<>
|
||||
<Select value={season} onValueChange={setSeason}>
|
||||
<SelectTrigger aria-label="季" className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="all">全部季</SelectItem>
|
||||
{seasons.map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
第 {n} 季
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={episode}
|
||||
onChange={(e) => setEpisode(e.target.value)}
|
||||
placeholder="集(可空)"
|
||||
aria-label="集"
|
||||
inputMode="numeric"
|
||||
className="w-24"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<Button type="submit" disabled={search.isPending}>
|
||||
{search.isPending ? <LoaderCircle className="animate-spin" /> : <Search />}
|
||||
搜索
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{data?.noSites ? (
|
||||
<p className="text-caption text-muted-foreground">
|
||||
还没有启用的字幕站。到「站点 → 添加站点 → 字幕站」添加 SubHD 或字幕库。
|
||||
</p>
|
||||
) : null}
|
||||
{data?.errors.length ? (
|
||||
<p className="text-meta text-warning">{data.errors.map((e) => `${e.siteName}:${e.reason}`).join(";")}</p>
|
||||
) : null}
|
||||
{data && !data.noSites && !data.results.length ? (
|
||||
<p className="text-caption text-muted-foreground">
|
||||
没搜到(用了:{data.keywords.join("、") || "无"})。可以换个关键词,或去掉季集限制再试。
|
||||
</p>
|
||||
) : null}
|
||||
{data?.results.length ? (
|
||||
<ul className="max-h-80 space-y-1.5 overflow-y-auto">
|
||||
{data.results.map((r) => {
|
||||
const key = `${r.siteId}${r.ref}`;
|
||||
return (
|
||||
<li key={key} className="flex min-w-0 items-center gap-2 rounded-lg border border-border/70 px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-caption text-foreground" title={r.title}>
|
||||
{r.title}
|
||||
</p>
|
||||
<p className="truncate text-meta text-muted-foreground">
|
||||
{[
|
||||
r.siteName,
|
||||
r.languages.join(" / ") || null,
|
||||
r.format,
|
||||
r.downloads !== null ? `下载 ${r.downloads}` : null,
|
||||
r.publishedAt ? (r.publishedAt.includes("T") ? relTime(r.publishedAt) : r.publishedAt) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="ghost" size="icon-sm" title="在字幕站打开">
|
||||
<a href={r.pageUrl} target="_blank" rel="noreferrer" aria-label="在字幕站打开">
|
||||
<ExternalLink />
|
||||
</a>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={fetching !== null} onClick={() => void take(r)}>
|
||||
{fetching === key ? <LoaderCircle className="animate-spin" /> : <Download />}
|
||||
下载
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,11 +21,12 @@ const VIEW_KEY = "umt.sites.view";
|
||||
type View = "cards" | "list";
|
||||
type Filter = "all" | "enabled" | "attention";
|
||||
/** 分组顺序;公开 BT 站的表只列它用得到的列 */
|
||||
const GROUPS: { kind: SiteKind; title: string; variant: "pt" | "public" }[] = [
|
||||
const GROUPS: { kind: SiteKind; title: string; variant: "pt" | "public" | "subtitle" }[] = [
|
||||
{ kind: "pt", title: "PT 站", variant: "pt" },
|
||||
{ kind: "public_bt", title: "公开 BT 站", variant: "public" },
|
||||
{ kind: "rss_feed", title: "RSS 源", variant: "public" },
|
||||
{ kind: "torznab", title: "索引器", variant: "public" },
|
||||
{ kind: "subtitle", title: "字幕站", variant: "subtitle" },
|
||||
];
|
||||
|
||||
const FILTERS: { value: Filter; label: string }[] = [
|
||||
|
||||
Reference in new issue
Block a user