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:
1 parent
7e4cdbf5ba
commit
173f2e24c9
6 files changed
+369
-8
No files matched your search
@@ -1,6 +1,7 @@
|
||||
// 在线字幕(docs/plan/subtitles.md 第 2 步):在已启用的字幕站(site.kind = subtitle)搜索、下载。
|
||||
// 在线字幕:在已启用的字幕站及 M-Team PT 站点搜索、下载。
|
||||
// 请求走站点出口 rt.siteFetch(站点代理、UA、Cloudflare 挑战走浏览器);页面解析与验证码识别在沙箱里(ADR-005)。
|
||||
// 流程对照 MP 插件 online_subtitles/providers/{subhd,zimuku}.py,页面结构 2026-10-03 实测。
|
||||
import { SiteError } from "@umt/adapters";
|
||||
import type { SubtitleSearchResponse, SubtitleSearchResult } from "@umt/contracts";
|
||||
import type { SiteRow } from "@umt/db";
|
||||
import { subtitleEpisode } from "@umt/domain";
|
||||
@@ -11,7 +12,7 @@ import type { Actor } from "../../browser/service.ts";
|
||||
import type { SourcesRuntime } from "../../sources-identity/runtime.ts";
|
||||
import { singleIdentityIds } from "../../sources-identity/service/identity-ids.ts";
|
||||
import type { ServerContext } from "../../types.ts";
|
||||
import type { PageEntry, PageKind, PageResult } from "./parse.ts";
|
||||
import { guessLanguages, type PageEntry, type PageKind, type PageResult } from "./parse.ts";
|
||||
|
||||
const MAX_DOWNLOAD_BYTES = 30 * 1024 * 1024;
|
||||
|
||||
@@ -142,7 +143,7 @@ function toResult(env: Env, e: PageEntry, q: SearchQuery): SubtitleSearchResult
|
||||
siteName: env.site.name,
|
||||
ref: e.ref,
|
||||
title: e.title,
|
||||
pageUrl: abs(env, e.ref),
|
||||
pageUrl: e.pageUrl ?? abs(env, e.ref),
|
||||
languages: e.languages,
|
||||
format: e.format,
|
||||
downloads: e.downloads,
|
||||
@@ -288,6 +289,57 @@ async function zimukuDownload(env: Env, ref: string): Promise<{ name: string; by
|
||||
throw new SubtitleSiteError(last);
|
||||
}
|
||||
|
||||
// ── M-Team:与种子搜索共用账号、API 地址和站点限流,搜索时不生成下载签名 ──
|
||||
|
||||
function mteamSubtitleId(value: string): string {
|
||||
if (!/^[A-Za-z0-9_-]{1,128}$/.test(value)) throw new SubtitleSiteError("M-Team 字幕 ID 非法,重新搜索后重试");
|
||||
return value;
|
||||
}
|
||||
|
||||
async function mteamSearch(env: Env, q: SearchQuery): Promise<PageEntry[]> {
|
||||
const adapter = env.rt.siteAdapter(env.site);
|
||||
if (!adapter.searchSubtitles) throw new SubtitleSiteError("M-Team 字幕搜索暂不可用");
|
||||
for (const keyword of q.keywords.slice(0, 3)) {
|
||||
const entries: PageEntry[] = [];
|
||||
for (let pageNumber = 1; pageNumber <= 3; pageNumber++) {
|
||||
const found = await adapter.searchSubtitles(keyword, { page: pageNumber });
|
||||
for (const item of found.items) {
|
||||
if (!relevant(item.title, q.keywords) || !episodeOk(item.title, q).ok) continue;
|
||||
const ref = mteamSubtitleId(item.id);
|
||||
const language = item.language?.replace(/^zh[-_]Hans$/i, "简体").replace(/^zh[-_]Hant$/i, "繁体") ?? "";
|
||||
entries.push({
|
||||
ref,
|
||||
title: item.title,
|
||||
pageUrl: item.torrentId ? abs(env, `/detail/${encodeURIComponent(item.torrentId)}`) : env.site.base_url,
|
||||
languages: guessLanguages(`${language} ${item.title} ${item.fileName ?? ""}`),
|
||||
format: /\.(srt|ass|ssa|vtt|sub|sbv)(?:$|\b)/i.exec(item.fileName ?? item.title)?.[1]?.toUpperCase() ?? null,
|
||||
downloads: item.downloads,
|
||||
uploader: item.uploader,
|
||||
publishedAt: item.uploadedAt,
|
||||
});
|
||||
}
|
||||
if (!found.items.length || (found.totalPages !== null && pageNumber >= found.totalPages)) break;
|
||||
if (found.totalPages === null && found.items.length < 20) break;
|
||||
}
|
||||
if (entries.length) return entries;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function mteamDownload(env: Env, ref: string): Promise<{ name: string; bytes: Uint8Array }> {
|
||||
const id = mteamSubtitleId(ref);
|
||||
const adapter = env.rt.siteAdapter(env.site);
|
||||
if (!adapter.downloadSubtitle) throw new SubtitleSiteError("M-Team 字幕下载暂不可用");
|
||||
try {
|
||||
const file = await adapter.downloadSubtitle(id);
|
||||
if (file.bytes.byteLength > MAX_DOWNLOAD_BYTES) throw new SubtitleSiteError("字幕包超过 30 MB");
|
||||
return { name: file.fileName ?? `mteam-${id}.zip`, bytes: file.bytes };
|
||||
} catch (error) {
|
||||
if (error instanceof SiteError) throw new SubtitleSiteError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const PROVIDERS: Record<
|
||||
string,
|
||||
{
|
||||
@@ -297,6 +349,7 @@ const PROVIDERS: Record<
|
||||
> = {
|
||||
subhd: { search: subhdSearch, download: subhdDownload },
|
||||
zimuku: { search: zimukuSearch, download: zimukuDownload },
|
||||
mteam: { search: mteamSearch, download: mteamDownload },
|
||||
};
|
||||
|
||||
// ── 对外 ──
|
||||
@@ -341,7 +394,7 @@ async function subtitleSites(ctx: ServerContext): Promise<SiteRow[]> {
|
||||
return await ctx.db
|
||||
.selectFrom("site")
|
||||
.selectAll()
|
||||
.where("kind", "=", "subtitle")
|
||||
.where((eb) => eb.or([eb("kind", "=", "subtitle"), eb.and([eb("kind", "=", "pt"), eb("key", "=", "mteam")])]))
|
||||
.where("enabled", "=", true)
|
||||
.where("deleted_at", "is", null)
|
||||
.orderBy("name")
|
||||
@@ -393,7 +446,12 @@ export async function downloadOnline(
|
||||
for (const c of cookies) jar.set(c.name, c.value);
|
||||
}
|
||||
const f = await PROVIDERS[site.key]!.download(env, ref);
|
||||
return { name: f.name, base64: b64encode(f.bytes), siteKey: site.key, pageUrl: abs(env, ref) };
|
||||
return {
|
||||
name: f.name,
|
||||
base64: b64encode(f.bytes),
|
||||
siteKey: site.key,
|
||||
pageUrl: site.key === "mteam" ? `mteam:subtitle:${mteamSubtitleId(ref)}` : abs(env, ref),
|
||||
};
|
||||
}
|
||||
|
||||
/** 字幕详情页地址 → 条目 ref(SubHD /a/<sid>、字幕库 /detail/<id>.html);不是详情页为 null */
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
export interface PageEntry {
|
||||
/** 站内引用:SubHD 为 /a/<sid>,字幕库为 /detail/<id>.html */
|
||||
ref: string;
|
||||
/** API 来源可给稳定详情页,不能放签名下载地址。 */
|
||||
pageUrl?: string;
|
||||
title: string;
|
||||
/** 简体 / 繁体 / 双语 / 英文 …(站点标注或从标题、说明里猜) */
|
||||
languages: string[];
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { createApp } from "../../src/http/app.ts";
|
||||
import { sourcesIdentityModule } from "../../src/modules/sources-identity/index.ts";
|
||||
import { createSubtitlesModule } from "../../src/modules/subtitles/index.ts";
|
||||
import { downloadOnline } from "../../src/modules/subtitles/online/index.ts";
|
||||
import { DEV_KEY } from "../helpers.ts";
|
||||
import { createHarness, type Harness, pgAvailable } from "../sources-identity/harness.ts";
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const subtitle = "1\n00:00:01,000 --> 00:00:03,000\n你好,字幕测试\n\n";
|
||||
|
||||
describe.skipIf(!hasPg)("M-Team在线字幕:真实API形状与写入历史", () => {
|
||||
let h: Harness;
|
||||
let app: ReturnType<typeof createApp>;
|
||||
let lib: string;
|
||||
let entityId: string;
|
||||
let locationId: string;
|
||||
let siteId: string;
|
||||
let accountId: string;
|
||||
let issued = 0;
|
||||
let failSearch = false;
|
||||
const calls: { path: string; url: string; headers: Headers; body: string | null }[] = [];
|
||||
const api = async (path: string, body?: unknown) => {
|
||||
const res = await app.request(`/api/v1${path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
headers: { authorization: `Bearer ${DEV_KEY}`, "content-type": "application/json" },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
return { status: res.status, body: await res.json() };
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
lib = await mkdtemp(join(tmpdir(), "umt-mteam-subs-"));
|
||||
h = await createHarness("mteam_subtitles", {
|
||||
fetch: async (input, init) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input));
|
||||
const headers = new Headers(init?.headers);
|
||||
calls.push({
|
||||
path: url.pathname,
|
||||
url: url.href,
|
||||
headers,
|
||||
body: typeof init?.body === "string" ? init.body : null,
|
||||
});
|
||||
expect(url.origin).toBe("https://mteam-api.example");
|
||||
if (url.pathname === "/api/subtitle/search") {
|
||||
expect(headers.get("x-api-key")).toBe("private-mteam-api-key");
|
||||
expect(headers.get("user-agent")).toBe("SubtitleRegression/1.0");
|
||||
if (failSearch) return new Response("<title>字幕服务维护</title>", { status: 503 });
|
||||
return Response.json({
|
||||
code: "0",
|
||||
message: "SUCCESS",
|
||||
data: {
|
||||
totalPages: 1,
|
||||
data: [
|
||||
{
|
||||
id: "sub-100",
|
||||
name: "示例剧.S01E02.简体",
|
||||
filename: "Example.Show.S01E02.chs.srt",
|
||||
lang: "zh-Hans",
|
||||
torrent: { id: "987" },
|
||||
hits: 23,
|
||||
author: "translator",
|
||||
createdDate: "2026-11-01T12:00:00+08:00",
|
||||
},
|
||||
{
|
||||
id: "sub-101",
|
||||
name: "示例剧.S01E02.双语",
|
||||
filename: "Example.Show.S01E02.chs&eng.ass",
|
||||
language: "chs&eng",
|
||||
hits: 5,
|
||||
},
|
||||
{ id: "sub-102", name: "示例剧.S02E02.简体", filename: "Example.Show.S02E02.srt" },
|
||||
{ id: "sub-103", name: "另一部剧.S01E02", filename: "Other.Show.srt" },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/api/subtitle/genlink") {
|
||||
expect(headers.get("x-api-key")).toBe("private-mteam-api-key");
|
||||
expect(new URLSearchParams(String(init?.body)).get("id")).toBe("sub-100");
|
||||
issued++;
|
||||
return Response.json({ code: "0", message: "SUCCESS", data: `private-credential-${issued}` });
|
||||
}
|
||||
if (url.pathname === "/api/subtitle/dlV2") {
|
||||
expect(url.searchParams.get("credential")).toBe(`private-credential-${issued}`);
|
||||
return new Response(subtitle, {
|
||||
headers: {
|
||||
"content-type": "application/octet-stream",
|
||||
"content-disposition": 'attachment; filename="Example.Show.S01E02.chs.srt"',
|
||||
},
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected path ${url.pathname}`);
|
||||
},
|
||||
});
|
||||
h.ctx.config.DATA_DIR = join(lib, ".data");
|
||||
vi.spyOn(sourcesIdentityModule, "runtime").mockReturnValue(h.rt);
|
||||
app = createApp({
|
||||
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
|
||||
logger: h.ctx.logger,
|
||||
bus: h.ctx.events,
|
||||
role: "all",
|
||||
version: "0.0.0-test",
|
||||
modules: [createSubtitlesModule()],
|
||||
ctx: h.ctx,
|
||||
});
|
||||
const root = await h.db
|
||||
.insertInto("library_root")
|
||||
.values({ name: "字幕测试库", media_kind: "mixed", local_path: lib, management_mode: "managed" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
entityId = (
|
||||
await h.db
|
||||
.insertInto("media_entity")
|
||||
.values({ kind: "series", title: "示例剧", year: 2024 })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
const asset = await h.db
|
||||
.insertInto("media_asset")
|
||||
.values({ media_entity_id: entityId, kind: "video", status: "active" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await writeFile(join(lib, "Example.Show.S01E02.mkv"), "video");
|
||||
locationId = (
|
||||
await h.db
|
||||
.insertInto("asset_location")
|
||||
.values({
|
||||
media_asset_id: asset.id,
|
||||
location_kind: "library",
|
||||
library_root_id: root.id,
|
||||
relative_path: "Example.Show.S01E02.mkv",
|
||||
status: "available",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
const unit = await h.db
|
||||
.insertInto("content_unit")
|
||||
.values({ media_entity_id: entityId, kind: "episode", season_number: 1, episode_number: 2 })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.insertInto("asset_unit_coverage")
|
||||
.values({ media_asset_id: asset.id, content_unit_id: unit.id })
|
||||
.execute();
|
||||
siteId = (
|
||||
await h.db
|
||||
.insertInto("site")
|
||||
.values({
|
||||
key: "mteam",
|
||||
name: "馒头",
|
||||
kind: "pt",
|
||||
adapter: "mteam_api",
|
||||
base_url: "https://kp.m-team.cc/",
|
||||
rate_limit: JSON.stringify({ minIntervalMs: 0, burst: 1 }),
|
||||
config: JSON.stringify({ apiBaseUrl: "https://mteam-api.example" }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
const secret = await h.secrets.create({ purpose: "site_api_key", plaintext: "private-mteam-api-key" });
|
||||
accountId = (
|
||||
await h.db
|
||||
.insertInto("site_account")
|
||||
.values({
|
||||
site_id: siteId,
|
||||
label: "main",
|
||||
auth_kind: "api_key",
|
||||
secret_ref: secret,
|
||||
user_agent: "SubtitleRegression/1.0",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await h?.close();
|
||||
if (lib) await rm(lib, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("已启用PT站纳入搜索,统一凭据和UA,只返回稳定ID和详情页", async () => {
|
||||
const result = await api("/subtitles/search", { entityId, season: 1, episode: 2 });
|
||||
expect(result.status, JSON.stringify(result.body)).toBe(200);
|
||||
expect(result.body.data.errors).toEqual([]);
|
||||
expect(result.body.data.noSites).toBe(false);
|
||||
expect(result.body.data.results).toHaveLength(2);
|
||||
expect(result.body.data.results.find((r: { ref: string }) => r.ref === "sub-100")).toMatchObject({
|
||||
siteId,
|
||||
siteKey: "mteam",
|
||||
siteName: "馒头",
|
||||
ref: "sub-100",
|
||||
pageUrl: "https://kp.m-team.cc/detail/987",
|
||||
languages: ["简体"],
|
||||
format: "SRT",
|
||||
downloads: 23,
|
||||
season: 1,
|
||||
episode: 2,
|
||||
});
|
||||
expect(result.body.data.results.find((r: { ref: string }) => r.ref === "sub-101").pageUrl).toBe(
|
||||
"https://kp.m-team.cc/",
|
||||
);
|
||||
expect(JSON.parse(calls.find((c) => c.path === "/api/subtitle/search")!.body!)).toEqual({
|
||||
keyword: "示例剧",
|
||||
pageNumber: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
expect(calls.some((c) => c.path === "/api/subtitle/genlink")).toBe(false);
|
||||
expect(JSON.stringify(result.body)).not.toMatch(/private-|credential|x-api-key/);
|
||||
});
|
||||
|
||||
it("下载时现场换签名,沙箱解析后写入与撤销,历史只记稳定字幕ID", async () => {
|
||||
const result = await api("/subtitles/fetch", { entityId, siteId, ref: "sub-100" });
|
||||
expect(result.status).toBe(200);
|
||||
const [item] = result.body.data.items;
|
||||
expect(item).toMatchObject({
|
||||
source: "mteam",
|
||||
sourceRef: "mteam:subtitle:sub-100",
|
||||
ext: "srt",
|
||||
targetLocationId: locationId,
|
||||
});
|
||||
expect(JSON.stringify(result.body)).not.toContain("private-");
|
||||
const written = await api("/subtitles/apply", { entityId, items: [item] });
|
||||
expect(written.status, JSON.stringify(written.body)).toBe(200);
|
||||
expect(written.body.data.failed).toEqual([]);
|
||||
const [write] = written.body.data.written;
|
||||
expect(write.source).toBe("mteam");
|
||||
expect(await readFile(join(lib, write.relativePath), "utf8")).toContain("你好");
|
||||
const history = await h.db
|
||||
.selectFrom("subtitle_write")
|
||||
.select(["source", "source_ref"])
|
||||
.where("id", "=", write.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(history).toEqual({ source: "mteam", source_ref: "mteam:subtitle:sub-100" });
|
||||
const fetchedAgain = await downloadOnline(h.ctx, h.rt, siteId, "sub-100");
|
||||
expect(fetchedAgain.pageUrl).toBe("mteam:subtitle:sub-100");
|
||||
expect(issued).toBe(2);
|
||||
const writes = await api(`/subtitles/writes?entityId=${entityId}`);
|
||||
expect(writes.body.data.items[0].source).toBe("mteam");
|
||||
const undone = await api(`/subtitles/writes/${write.id}/undo`, {});
|
||||
expect(undone.status).toBe(200);
|
||||
expect(undone.body.data.undoneAt).toBeTruthy();
|
||||
await expect(readFile(join(lib, write.relativePath))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("拒绝把下载签名或URL作为字幕引用,无需发请求", async () => {
|
||||
const before = calls.length;
|
||||
await expect(
|
||||
downloadOnline(h.ctx, h.rt, siteId, "https://mteam-api.example/api/subtitle/dlV2?credential=private-signature"),
|
||||
).rejects.toThrow(/字幕 ID 非法/);
|
||||
expect(calls).toHaveLength(before);
|
||||
});
|
||||
|
||||
it("搜索接口失败保留状态和标题", async () => {
|
||||
failSearch = true;
|
||||
const result = await api("/subtitles/search", { entityId });
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.data.errors[0]).toMatchObject({ siteName: "馒头" });
|
||||
expect(result.body.data.errors[0].reason).toContain("HTTP 503");
|
||||
expect(result.body.data.errors[0].reason).toContain("字幕服务维护");
|
||||
failSearch = false;
|
||||
});
|
||||
|
||||
it("账号失效和站点停用给出明确结果,不继续使用缓存凭据", async () => {
|
||||
await h.db.updateTable("site_account").set({ enabled: false }).where("id", "=", accountId).execute();
|
||||
const before = calls.length;
|
||||
const result = await api("/subtitles/search", { entityId });
|
||||
expect(result.body.data.errors[0].reason).toMatch(/API Key|凭据|密钥/);
|
||||
expect(calls).toHaveLength(before);
|
||||
await h.db.updateTable("site").set({ enabled: false }).where("id", "=", siteId).execute();
|
||||
expect((await api("/subtitles/search", { entityId })).body.data.noSites).toBe(true);
|
||||
await expect(downloadOnline(h.ctx, h.rt, siteId, "sub-100")).rejects.toThrow(/已停用/);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ export const SubtitleLanguage = z.enum([
|
||||
]);
|
||||
export type SubtitleLanguage = z.infer<typeof SubtitleLanguage>;
|
||||
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<typeof SubtitleSource>;
|
||||
|
||||
/** 可写字幕的视频(作品在媒体库里的视频位置) */
|
||||
@@ -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,
|
||||
|
||||
@@ -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<any>): Promise<void> {
|
||||
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<any>): Promise<void> {
|
||||
// 有新来源的历史时拒绝回退,不删除记录或改写其来源。
|
||||
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"])};`,
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user