feat: close product loop for online danmaku
DanmakuFetchOutput.reason (no-app-cred/need-login/no-match/...); watch page settings/clear-local CTAs; dandanplay login for all signed-in users; cache before login gate; README first-run checklist.
This commit is contained in:
parent
4fe0f40ce4
commit
e0ecf5b106
21
README.md
21
README.md
|
|
@ -50,3 +50,24 @@ yarn dev:web # Astro :4321
|
|||
`web → trpc → dao → db → models`,横切 `types`。禁止循环依赖;跨包只走 `package.json` 的 `exports`。
|
||||
|
||||
新增业务实体请按既有分层接线:`types` schema → `models` 表 → `dao` 查询 → `trpc` service/router → `web` 页面。细节见根目录 `AGENTS.md`。
|
||||
|
||||
|
||||
## 首次运行配置(产品闭环)
|
||||
|
||||
1. 注册第一个账号(自动成为**管理员**)
|
||||
2. **设置 → 开放弹幕网络**:填写弹弹play DevCenter 申请的 `AppId` / `AppSecret`([dev.dandanplay.com](https://dev.dandanplay.com))
|
||||
3. **设置 → 弹弹play 账号**:登录弹弹play 账号(站点共用会话,用于拉在线弹幕)
|
||||
4. 挂载 WebDAV → 扫描/入库 → 播放页自动匹配或手选作品/集数
|
||||
|
||||
未完成 2/3 时播放页会提示「去设置」;本地 XML 导入不受影响。
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker build -t fanmu:local .
|
||||
docker run -d -p 4330:4321 \
|
||||
-e MEDIA_SECRET_KEY=<64-hex> \
|
||||
-v $PWD/packages/db/local.db:/app/data/local.db \
|
||||
fanmu:local
|
||||
# 然后走上面「首次运行配置」
|
||||
```
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ function Inner(props: { locale: Locale }) {
|
|||
const isAdmin = me.data?.user?.role === "admin";
|
||||
const openNet = trpc.settings.getOpenNetwork.useQuery(undefined, { enabled: isAdmin });
|
||||
const saveOpenNet = trpc.settings.saveOpenNetwork.useMutation();
|
||||
const ddpStatus = trpc.settings.dandanplayAuthStatus.useQuery(undefined, { enabled: isAdmin });
|
||||
const ddpStatus = trpc.settings.dandanplayAuthStatus.useQuery(undefined, { enabled: Boolean(me.data?.user) });
|
||||
const ddpLogin = trpc.settings.loginDandanplay.useMutation();
|
||||
const ddpLogout = trpc.settings.logoutDandanplay.useMutation();
|
||||
const [ddpUser, setDdpUser] = useState("");
|
||||
|
|
@ -31,7 +31,7 @@ function Inner(props: { locale: Locale }) {
|
|||
if (me.isLoading) {
|
||||
return <p className="text-sm text-muted-foreground">{t(locale, "watch.loading")}</p>;
|
||||
}
|
||||
if (!isAdmin) {
|
||||
if (!isAdmin && !me.data?.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -110,6 +110,7 @@ function Inner(props: { locale: Locale }) {
|
|||
<div className="space-y-2 border-t border-border pt-3">
|
||||
<p className="text-sm font-medium">{label("settings.ddpLogin")}</p>
|
||||
<p className="text-xs text-muted-foreground">{label("settings.ddpLoginHint")}</p>
|
||||
<p className="text-xs text-muted-foreground">{label("settings.sharedAccountHint")}</p>
|
||||
{ddpStatus.data?.loggedIn ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm">
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
|||
const prefs = trpc.media.danmakuGetSettings.useQuery();
|
||||
const report = trpc.media.playbackReport.useMutation();
|
||||
const importXml = trpc.media.danmakuImport.useMutation();
|
||||
const clearLocal = trpc.media.danmakuClearLocal.useMutation();
|
||||
const matchMut = trpc.media.danmakuMatch.useMutation();
|
||||
const selectMut = trpc.media.danmakuSelectMatch.useMutation();
|
||||
// 手动指定弹幕源:作品 → 集数
|
||||
|
|
@ -635,13 +636,19 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
|||
? `${t(locale, "watch.danmakuLocal")} · ${t(locale, "watch.danmakuCount", { count: danmakuCount })}`
|
||||
: matchXml
|
||||
? t(locale, "watch.danmakuOpenNetwork")
|
||||
: openDanmaku.data?.source === "none"
|
||||
? t(locale, "watch.noDanmakuImport")
|
||||
: openDanmaku.data?.source === "cache"
|
||||
? t(locale, "watch.danmakuCache")
|
||||
: openDanmaku.data?.source === "open-network"
|
||||
? t(locale, "watch.danmakuOpenNetwork")
|
||||
: t(locale, "watch.danmakuLoading");
|
||||
: openDanmaku.data?.reason === "need-login"
|
||||
? t(locale, "watch.needDdpLogin")
|
||||
: openDanmaku.data?.reason === "no-app-cred"
|
||||
? t(locale, "watch.noAppCred")
|
||||
: openDanmaku.data?.reason === "no-match"
|
||||
? t(locale, "watch.noDanmakuImport")
|
||||
: openDanmaku.data?.source === "none"
|
||||
? t(locale, "watch.noDanmakuImport")
|
||||
: openDanmaku.data?.source === "cache"
|
||||
? t(locale, "watch.danmakuCache")
|
||||
: openDanmaku.data?.source === "open-network"
|
||||
? t(locale, "watch.danmakuOpenNetwork")
|
||||
: t(locale, "watch.danmakuLoading");
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
|
|
@ -707,6 +714,40 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
|||
{t(locale, "watch.rematch")}
|
||||
</Button>
|
||||
)}
|
||||
{(openDanmaku.data?.reason === "need-login" ||
|
||||
openDanmaku.data?.reason === "no-app-cred") && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => go(`/${locale}/settings`)}
|
||||
>
|
||||
{t(locale, "watch.gotoSettings")}
|
||||
</Button>
|
||||
)}
|
||||
{isLocalSource && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={clearLocal.isPending}
|
||||
onClick={() =>
|
||||
clearLocal.mutate(
|
||||
{ mediaItemId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setLocalXml("");
|
||||
clearLocalXml(mediaItemId);
|
||||
void openDanmaku.refetch();
|
||||
toast.success(
|
||||
t(locale, "watch.clearLocalOk"),
|
||||
);
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{t(locale, "watch.clearLocalDanmaku")}
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
<span>{t(locale, "watch.progressHint")}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -353,4 +353,10 @@ export const en: Record<MessageKey, string> = {
|
|||
"settings.ddpLoginFailed": "Sign-in failed",
|
||||
"settings.ddpLoggedInAs": "Signed in as {name}",
|
||||
"watch.needDdpLogin": "Sign in to DanDanPlay in Settings to load online danmaku",
|
||||
"watch.gotoSettings": "Open settings",
|
||||
"watch.noAppCred": "Open danmaku network not configured",
|
||||
"watch.clearLocalDanmaku": "Clear local danmaku",
|
||||
"watch.clearLocalOk": "Local danmaku cleared",
|
||||
"settings.nonAdminHint": "Admin configures the open network; any signed-in user can bind the shared DanDanPlay session here.",
|
||||
"settings.sharedAccountHint": "The DanDanPlay account is a site-wide session used to fetch online danmaku.",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -343,6 +343,12 @@ export const zhCN = {
|
|||
"settings.ddpLoginFailed": "登录失败",
|
||||
"settings.ddpLoggedInAs": "已登录:{name}",
|
||||
"watch.needDdpLogin": "获取在线弹幕需登录弹弹play 账号(设置页)",
|
||||
"watch.gotoSettings": "去设置",
|
||||
"watch.noAppCred": "未配置开放弹幕网络",
|
||||
"watch.clearLocalDanmaku": "清除本地弹幕",
|
||||
"watch.clearLocalOk": "已清除本地弹幕",
|
||||
"settings.nonAdminHint": "在线弹幕需管理员配置开放网络;普通账号可在此登录弹弹play 会话。",
|
||||
"settings.sharedAccountHint": "弹弹play 账号为站点共用会话,用于拉取在线弹幕。",
|
||||
} as const;
|
||||
|
||||
export type MessageKey = keyof typeof zhCN;
|
||||
|
|
|
|||
|
|
@ -143,6 +143,11 @@ export const mediaRouter = t.router({
|
|||
danmakuSourceEpisodes: protectedProcedure
|
||||
.input(danmakuSchemas.sourceEpisodes)
|
||||
.query(({ input }) => danmakuService.sourceEpisodes(input.animeId)),
|
||||
danmakuClearLocal: protectedProcedure
|
||||
.input(danmakuSchemas.clearLocal)
|
||||
.mutation(({ ctx, input }) =>
|
||||
danmakuService.clearLocalDanmaku(ctx.userId, input.mediaItemId),
|
||||
),
|
||||
danmakuImport: protectedProcedure
|
||||
.input(danmakuSchemas.import)
|
||||
.mutation(({ ctx, input }) =>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { settingsSchemas } from "@app/types";
|
||||
import { adminProcedure } from "../services/procedure.js";
|
||||
import { adminProcedure, protectedProcedure } from "../services/procedure.js";
|
||||
import { settingsService } from "../services/settings.service.js";
|
||||
import { t } from "../context.js";
|
||||
|
||||
|
|
@ -11,15 +11,15 @@ export const settingsRouter = t.router({
|
|||
saveOpenNetwork: adminProcedure
|
||||
.input(settingsSchemas.saveOpenNetwork)
|
||||
.mutation(({ ctx, input }) => settingsService.saveOpenNetworkSettings(ctx.userId, input)),
|
||||
loginDandanplay: adminProcedure
|
||||
loginDandanplay: protectedProcedure
|
||||
.input(settingsSchemas.loginDandanplay)
|
||||
.mutation(({ ctx, input }) =>
|
||||
settingsService.loginDandanplay(ctx.userId, input.username, input.password),
|
||||
),
|
||||
logoutDandanplay: adminProcedure
|
||||
logoutDandanplay: protectedProcedure
|
||||
.input(settingsSchemas.logoutDandanplay)
|
||||
.mutation(({ ctx }) => settingsService.logoutDandanplay(ctx.userId)),
|
||||
dandanplayAuthStatus: adminProcedure
|
||||
dandanplayAuthStatus: protectedProcedure
|
||||
.input(settingsSchemas.logoutDandanplay)
|
||||
.query(() => settingsService.getDandanplayAuthStatus()),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { createWebdav } from "./webdav-client.js";
|
|||
|
||||
vi.mock("./settings.service.js", () => ({
|
||||
ensureOpenRuntime: vi.fn(async () => {}),
|
||||
getDandanplayAuthStatus: vi.fn(async () => ({ loggedIn: true, screenName: "u" })),
|
||||
settingsService: {},
|
||||
}));
|
||||
|
||||
|
|
@ -205,7 +206,7 @@ describe("danmakuService.fetch", () => {
|
|||
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
||||
vi.mocked(danmakuDocsDao.get).mockResolvedValue(docRow({ xml: "<i>local</i>" }));
|
||||
const r = await danmakuService.fetch("u1", "m1");
|
||||
expect(r).toEqual({ ok: true, source: "local-xml", xml: "<i>local</i>" });
|
||||
expect(r).toMatchObject({ ok: true, source: "local-xml", xml: "<i>local</i>" });
|
||||
expect(danmakuDocsDao.get).toHaveBeenCalledWith("u1", "m1", "local-xml");
|
||||
expect(openMatch).not.toHaveBeenCalled();
|
||||
expect(openCommentXml).not.toHaveBeenCalled();
|
||||
|
|
@ -228,7 +229,7 @@ describe("danmakuService.fetch", () => {
|
|||
cacheRow({ payload: "<i>cached</i>" }),
|
||||
);
|
||||
const r = await danmakuService.fetch("u1", "m1");
|
||||
expect(r).toEqual({ ok: true, source: "cache", xml: "<i>cached</i>" });
|
||||
expect(r).toMatchObject({ ok: true, source: "cache", xml: "<i>cached</i>" });
|
||||
expect(danmakuCacheDao.getValid).toHaveBeenCalledWith("77");
|
||||
expect(openCommentXml).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -239,7 +240,7 @@ describe("danmakuService.fetch", () => {
|
|||
vi.mocked(hasOpenCredentials).mockReturnValue(false);
|
||||
const r = await danmakuService.fetch("u1", "m1");
|
||||
expect(r.source).toBe("none");
|
||||
expect(r.message).toContain("账户设置");
|
||||
expect(r.message).toContain("设置 → 开放弹幕网络");
|
||||
expect(openMatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -264,7 +265,7 @@ describe("danmakuService.fetch", () => {
|
|||
});
|
||||
vi.mocked(openCommentXml).mockResolvedValue("<i>open</i>");
|
||||
const r = await danmakuService.fetch("u1", "m1");
|
||||
expect(r).toEqual({ ok: true, source: "open-network", xml: "<i>open</i>" });
|
||||
expect(r).toMatchObject({ ok: true, source: "open-network", xml: "<i>open</i>" });
|
||||
expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalledWith("m1", "u1", {
|
||||
episodeId: 555,
|
||||
matchedHash: null,
|
||||
|
|
@ -313,7 +314,7 @@ describe("danmakuService.selectMatch", () => {
|
|||
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem({ matchedHash: "abc" }));
|
||||
vi.mocked(openCommentXml).mockResolvedValue("<i>open</i>");
|
||||
const r = await danmakuService.selectMatch("u1", "m1", 4242);
|
||||
expect(r).toEqual({ ok: true, source: "open-network", xml: "<i>open</i>" });
|
||||
expect(r).toMatchObject({ ok: true, source: "open-network", xml: "<i>open</i>" });
|
||||
expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalledWith("m1", "u1", {
|
||||
episodeId: 4242,
|
||||
matchedHash: "abc",
|
||||
|
|
@ -642,14 +643,14 @@ describe("danmakuService.fetch 同名侧车", () => {
|
|||
vi.mocked(hasOpenCredentials).mockReturnValue(false);
|
||||
const r = await danmakuService.fetch("u1", "m1");
|
||||
expect(r.source).toBe("none");
|
||||
expect(r.message).toContain("账户设置");
|
||||
expect(r.message).toContain("设置 → 开放弹幕网络");
|
||||
});
|
||||
|
||||
it("已有 local-xml 时不扫描侧车", async () => {
|
||||
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
||||
vi.mocked(danmakuDocsDao.get).mockResolvedValue(docRow({ xml: "<i>local</i>" }));
|
||||
const r = await danmakuService.fetch("u1", "m1");
|
||||
expect(r).toEqual({ ok: true, source: "local-xml", xml: "<i>local</i>" });
|
||||
expect(r).toMatchObject({ ok: true, source: "local-xml", xml: "<i>local</i>" });
|
||||
expect(createWebdav).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import type {
|
|||
DanmakuSourceSearchOutput,
|
||||
} from "@app/types";
|
||||
import { decryptSecret } from "./secret.js";
|
||||
import { ensureOpenRuntime } from "./settings.service.js";
|
||||
import { ensureOpenRuntime, getDandanplayAuthStatus } from "./settings.service.js";
|
||||
import { createWebdav, joinWebdavPath, listDirectory, type DirEntry } from "./webdav-client.js";
|
||||
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
|
@ -139,10 +139,20 @@ export const danmakuService = {
|
|||
async fetch(userId: string, mediaItemId: string): Promise<DanmakuFetchOutput> {
|
||||
await ensureOpenRuntime();
|
||||
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
||||
if (!item) return { ok: false, source: "none", xml: "", message: "媒体不存在" };
|
||||
if (!item) {
|
||||
return {
|
||||
ok: false,
|
||||
source: "none",
|
||||
xml: "",
|
||||
reason: "not-found",
|
||||
message: "媒体不存在",
|
||||
};
|
||||
}
|
||||
|
||||
const local = await danmakuDocsDao.get(userId, mediaItemId, "local-xml");
|
||||
if (local) return { ok: true, source: "local-xml", xml: local.xml };
|
||||
if (local) {
|
||||
return { ok: true, source: "local-xml", xml: local.xml, reason: "local-xml" };
|
||||
}
|
||||
|
||||
// 同目录同名 .xml 侧车自动关联;命中即按 local-xml 返回
|
||||
if (await danmakuService.ensureSidecarXml(userId, mediaItemId)) {
|
||||
|
|
@ -151,6 +161,7 @@ export const danmakuService = {
|
|||
ok: true,
|
||||
source: "local-xml",
|
||||
xml: linked?.xml ?? "",
|
||||
reason: "local-xml",
|
||||
message: "同名弹幕",
|
||||
};
|
||||
}
|
||||
|
|
@ -160,7 +171,8 @@ export const danmakuService = {
|
|||
ok: true,
|
||||
source: "none",
|
||||
xml: "",
|
||||
message: "未配置开放弹幕网络:管理员请在「账户设置」页填写 AppId / AppSecret",
|
||||
reason: "no-app-cred",
|
||||
message: "未配置开放弹幕网络:请在「设置 → 开放弹幕网络」填写 AppId / AppSecret",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -169,7 +181,22 @@ export const danmakuService = {
|
|||
? String(item.dandanplayEpisodeId)
|
||||
: matchKeyFor(item.path, item.size);
|
||||
const cached = await danmakuCacheDao.getValid(cacheKey);
|
||||
if (cached) return { ok: true, source: "cache", xml: cached.payload };
|
||||
if (cached) {
|
||||
return { ok: true, source: "cache", xml: cached.payload, reason: "cache" };
|
||||
}
|
||||
|
||||
{
|
||||
const auth = await getDandanplayAuthStatus();
|
||||
if (!auth.loggedIn) {
|
||||
return {
|
||||
ok: true,
|
||||
source: "none",
|
||||
xml: "",
|
||||
reason: "need-login",
|
||||
message: "获取在线弹幕需登录弹弹play 账号(设置页)",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let episodeId = item.dandanplayEpisodeId ?? null;
|
||||
if (episodeId == null) {
|
||||
|
|
@ -187,6 +214,7 @@ export const danmakuService = {
|
|||
ok: true,
|
||||
source: "none",
|
||||
xml: "",
|
||||
reason: "no-match",
|
||||
message: outcome.errorMessage ?? "开放网络未匹配到弹幕",
|
||||
};
|
||||
}
|
||||
|
|
@ -196,12 +224,15 @@ export const danmakuService = {
|
|||
ok: true,
|
||||
source: "none",
|
||||
xml: "",
|
||||
reason: "manual-select",
|
||||
message: "匹配不明确,请在播放页手选弹幕集",
|
||||
};
|
||||
}
|
||||
episodeId = ranked.candidates[0]?.episodeId ?? null;
|
||||
if (episodeId == null) {
|
||||
return { ok: true, source: "none", xml: "", message: "开放网络未匹配到弹幕" };
|
||||
return { ok: true, source: "none", xml: "", reason: "no-match",
|
||||
message: "开放网络未匹配到弹幕",
|
||||
};
|
||||
}
|
||||
await mediaItemDao.setDanmakuMatch(mediaItemId, userId, {
|
||||
episodeId,
|
||||
|
|
@ -212,11 +243,17 @@ export const danmakuService = {
|
|||
|
||||
const xml = await openCommentXml(episodeId);
|
||||
if (xml === null) {
|
||||
return { ok: true, source: "none", xml: "", message: "弹幕库拉取失败" };
|
||||
return {
|
||||
ok: true,
|
||||
source: "none",
|
||||
xml: "",
|
||||
reason: "fetch-failed",
|
||||
message: "弹幕库拉取失败",
|
||||
};
|
||||
}
|
||||
await persistOpenXml(userId, mediaItemId, xml);
|
||||
await persistOpenCache(episodeId, xml);
|
||||
return { ok: true, source: "open-network", xml };
|
||||
return { ok: true, source: "open-network", xml, reason: "ok" };
|
||||
},
|
||||
|
||||
async matchCandidates(userId: string, mediaItemId: string): Promise<DanmakuMatchOutput> {
|
||||
|
|
@ -235,7 +272,7 @@ export const danmakuService = {
|
|||
return {
|
||||
ok: false,
|
||||
matched: false,
|
||||
message: "未配置开放弹幕网络",
|
||||
message: "未配置开放弹幕网络:请在「设置 → 开放弹幕网络」配置",
|
||||
candidates: [],
|
||||
highConfidence: false,
|
||||
};
|
||||
|
|
@ -265,7 +302,15 @@ export const danmakuService = {
|
|||
): Promise<DanmakuFetchOutput> {
|
||||
await ensureOpenRuntime();
|
||||
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
||||
if (!item) return { ok: false, source: "none", xml: "", message: "媒体不存在" };
|
||||
if (!item) {
|
||||
return {
|
||||
ok: false,
|
||||
source: "none",
|
||||
xml: "",
|
||||
reason: "not-found",
|
||||
message: "媒体不存在",
|
||||
};
|
||||
}
|
||||
await mediaItemDao.setDanmakuMatch(mediaItemId, userId, {
|
||||
episodeId,
|
||||
matchedHash: item.matchedHash,
|
||||
|
|
@ -275,11 +320,25 @@ export const danmakuService = {
|
|||
await danmakuDocsDao.remove(userId, mediaItemId, "local-xml");
|
||||
const xml = await openCommentXml(episodeId);
|
||||
if (xml === null) {
|
||||
return { ok: true, source: "none", xml: "", message: "弹幕库拉取失败" };
|
||||
return {
|
||||
ok: true,
|
||||
source: "none",
|
||||
xml: "",
|
||||
reason: "fetch-failed",
|
||||
message: "弹幕库拉取失败",
|
||||
};
|
||||
}
|
||||
await persistOpenXml(userId, mediaItemId, xml);
|
||||
await persistOpenCache(episodeId, xml);
|
||||
return { ok: true, source: "open-network", xml };
|
||||
return { ok: true, source: "open-network", xml, reason: "ok" };
|
||||
},
|
||||
|
||||
/** 清除本地导入/侧车弹幕,恢复开放网络优先 */
|
||||
async clearLocalDanmaku(userId: string, mediaItemId: string): Promise<{ ok: true }> {
|
||||
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
||||
if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "媒体不存在" });
|
||||
await danmakuDocsDao.remove(userId, mediaItemId, "local-xml");
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
async importXml(
|
||||
|
|
@ -310,7 +369,7 @@ export const danmakuService = {
|
|||
async searchSource(keyword: string): Promise<DanmakuSourceSearchOutput> {
|
||||
await ensureOpenRuntime();
|
||||
if (!hasOpenCredentials()) {
|
||||
return { ok: false, message: "未配置开放弹幕网络", animes: [] };
|
||||
return { ok: false, message: "未配置开放弹幕网络:请在「设置 → 开放弹幕网络」配置", animes: [] };
|
||||
}
|
||||
const outcome = await openSearchEpisodes(keyword);
|
||||
if (!outcome.ok) {
|
||||
|
|
@ -334,7 +393,7 @@ export const danmakuService = {
|
|||
if (!hasOpenCredentials()) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "未配置开放弹幕网络",
|
||||
message: "未配置开放弹幕网络:请在「设置 → 开放弹幕网络」配置",
|
||||
animeId,
|
||||
animeTitle: null,
|
||||
episodes: [],
|
||||
|
|
|
|||
|
|
@ -222,6 +222,8 @@ export const danmakuSchemas = {
|
|||
.refine((v) => v.includes("<d "), "不是有效的弹幕 XML"),
|
||||
fileName: z.string().max(255).optional(),
|
||||
}),
|
||||
/** 清除该条目的本地弹幕文档 */
|
||||
clearLocal: z.object({ mediaItemId: id }),
|
||||
settings: z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
opacity: z.number().min(0).max(1).default(0.8),
|
||||
|
|
@ -250,11 +252,24 @@ export type BatchResult = {
|
|||
skipped: number;
|
||||
message: string;
|
||||
};
|
||||
export type DanmakuFetchReason =
|
||||
| "ok"
|
||||
| "local-xml"
|
||||
| "cache"
|
||||
| "no-app-cred"
|
||||
| "need-login"
|
||||
| "no-match"
|
||||
| "fetch-failed"
|
||||
| "not-found"
|
||||
| "manual-select";
|
||||
|
||||
export type DanmakuFetchOutput = {
|
||||
ok: boolean;
|
||||
source: "open-network" | "cache" | "local-xml" | "none";
|
||||
/** Bilibili/弹弹play 兼容 XML 字符串;无弹幕则为 "" */
|
||||
xml: string;
|
||||
/** 机器可读原因,UI 据此给出路(配置/登录/手选) */
|
||||
reason?: DanmakuFetchReason | undefined;
|
||||
message?: string | undefined;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue