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`。
|
`web → trpc → dao → db → models`,横切 `types`。禁止循环依赖;跨包只走 `package.json` 的 `exports`。
|
||||||
|
|
||||||
新增业务实体请按既有分层接线:`types` schema → `models` 表 → `dao` 查询 → `trpc` service/router → `web` 页面。细节见根目录 `AGENTS.md`。
|
新增业务实体请按既有分层接线:`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 isAdmin = me.data?.user?.role === "admin";
|
||||||
const openNet = trpc.settings.getOpenNetwork.useQuery(undefined, { enabled: isAdmin });
|
const openNet = trpc.settings.getOpenNetwork.useQuery(undefined, { enabled: isAdmin });
|
||||||
const saveOpenNet = trpc.settings.saveOpenNetwork.useMutation();
|
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 ddpLogin = trpc.settings.loginDandanplay.useMutation();
|
||||||
const ddpLogout = trpc.settings.logoutDandanplay.useMutation();
|
const ddpLogout = trpc.settings.logoutDandanplay.useMutation();
|
||||||
const [ddpUser, setDdpUser] = useState("");
|
const [ddpUser, setDdpUser] = useState("");
|
||||||
|
|
@ -31,7 +31,7 @@ function Inner(props: { locale: Locale }) {
|
||||||
if (me.isLoading) {
|
if (me.isLoading) {
|
||||||
return <p className="text-sm text-muted-foreground">{t(locale, "watch.loading")}</p>;
|
return <p className="text-sm text-muted-foreground">{t(locale, "watch.loading")}</p>;
|
||||||
}
|
}
|
||||||
if (!isAdmin) {
|
if (!isAdmin && !me.data?.user) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,6 +110,7 @@ function Inner(props: { locale: Locale }) {
|
||||||
<div className="space-y-2 border-t border-border pt-3">
|
<div className="space-y-2 border-t border-border pt-3">
|
||||||
<p className="text-sm font-medium">{label("settings.ddpLogin")}</p>
|
<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.ddpLoginHint")}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{label("settings.sharedAccountHint")}</p>
|
||||||
{ddpStatus.data?.loggedIn ? (
|
{ddpStatus.data?.loggedIn ? (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
||||||
const prefs = trpc.media.danmakuGetSettings.useQuery();
|
const prefs = trpc.media.danmakuGetSettings.useQuery();
|
||||||
const report = trpc.media.playbackReport.useMutation();
|
const report = trpc.media.playbackReport.useMutation();
|
||||||
const importXml = trpc.media.danmakuImport.useMutation();
|
const importXml = trpc.media.danmakuImport.useMutation();
|
||||||
|
const clearLocal = trpc.media.danmakuClearLocal.useMutation();
|
||||||
const matchMut = trpc.media.danmakuMatch.useMutation();
|
const matchMut = trpc.media.danmakuMatch.useMutation();
|
||||||
const selectMut = trpc.media.danmakuSelectMatch.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 })}`
|
? `${t(locale, "watch.danmakuLocal")} · ${t(locale, "watch.danmakuCount", { count: danmakuCount })}`
|
||||||
: matchXml
|
: matchXml
|
||||||
? t(locale, "watch.danmakuOpenNetwork")
|
? t(locale, "watch.danmakuOpenNetwork")
|
||||||
: openDanmaku.data?.source === "none"
|
: openDanmaku.data?.reason === "need-login"
|
||||||
? t(locale, "watch.noDanmakuImport")
|
? t(locale, "watch.needDdpLogin")
|
||||||
: openDanmaku.data?.source === "cache"
|
: openDanmaku.data?.reason === "no-app-cred"
|
||||||
? t(locale, "watch.danmakuCache")
|
? t(locale, "watch.noAppCred")
|
||||||
: openDanmaku.data?.source === "open-network"
|
: openDanmaku.data?.reason === "no-match"
|
||||||
? t(locale, "watch.danmakuOpenNetwork")
|
? t(locale, "watch.noDanmakuImport")
|
||||||
: t(locale, "watch.danmakuLoading");
|
: 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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -707,6 +714,40 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
||||||
{t(locale, "watch.rematch")}
|
{t(locale, "watch.rematch")}
|
||||||
</Button>
|
</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>
|
||||||
<span>{t(locale, "watch.progressHint")}</span>
|
<span>{t(locale, "watch.progressHint")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -353,4 +353,10 @@ export const en: Record<MessageKey, string> = {
|
||||||
"settings.ddpLoginFailed": "Sign-in failed",
|
"settings.ddpLoginFailed": "Sign-in failed",
|
||||||
"settings.ddpLoggedInAs": "Signed in as {name}",
|
"settings.ddpLoggedInAs": "Signed in as {name}",
|
||||||
"watch.needDdpLogin": "Sign in to DanDanPlay in Settings to load online danmaku",
|
"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.ddpLoginFailed": "登录失败",
|
||||||
"settings.ddpLoggedInAs": "已登录:{name}",
|
"settings.ddpLoggedInAs": "已登录:{name}",
|
||||||
"watch.needDdpLogin": "获取在线弹幕需登录弹弹play 账号(设置页)",
|
"watch.needDdpLogin": "获取在线弹幕需登录弹弹play 账号(设置页)",
|
||||||
|
"watch.gotoSettings": "去设置",
|
||||||
|
"watch.noAppCred": "未配置开放弹幕网络",
|
||||||
|
"watch.clearLocalDanmaku": "清除本地弹幕",
|
||||||
|
"watch.clearLocalOk": "已清除本地弹幕",
|
||||||
|
"settings.nonAdminHint": "在线弹幕需管理员配置开放网络;普通账号可在此登录弹弹play 会话。",
|
||||||
|
"settings.sharedAccountHint": "弹弹play 账号为站点共用会话,用于拉取在线弹幕。",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type MessageKey = keyof typeof zhCN;
|
export type MessageKey = keyof typeof zhCN;
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,11 @@ export const mediaRouter = t.router({
|
||||||
danmakuSourceEpisodes: protectedProcedure
|
danmakuSourceEpisodes: protectedProcedure
|
||||||
.input(danmakuSchemas.sourceEpisodes)
|
.input(danmakuSchemas.sourceEpisodes)
|
||||||
.query(({ input }) => danmakuService.sourceEpisodes(input.animeId)),
|
.query(({ input }) => danmakuService.sourceEpisodes(input.animeId)),
|
||||||
|
danmakuClearLocal: protectedProcedure
|
||||||
|
.input(danmakuSchemas.clearLocal)
|
||||||
|
.mutation(({ ctx, input }) =>
|
||||||
|
danmakuService.clearLocalDanmaku(ctx.userId, input.mediaItemId),
|
||||||
|
),
|
||||||
danmakuImport: protectedProcedure
|
danmakuImport: protectedProcedure
|
||||||
.input(danmakuSchemas.import)
|
.input(danmakuSchemas.import)
|
||||||
.mutation(({ ctx, input }) =>
|
.mutation(({ ctx, input }) =>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { settingsSchemas } from "@app/types";
|
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 { settingsService } from "../services/settings.service.js";
|
||||||
import { t } from "../context.js";
|
import { t } from "../context.js";
|
||||||
|
|
||||||
|
|
@ -11,15 +11,15 @@ export const settingsRouter = t.router({
|
||||||
saveOpenNetwork: adminProcedure
|
saveOpenNetwork: adminProcedure
|
||||||
.input(settingsSchemas.saveOpenNetwork)
|
.input(settingsSchemas.saveOpenNetwork)
|
||||||
.mutation(({ ctx, input }) => settingsService.saveOpenNetworkSettings(ctx.userId, input)),
|
.mutation(({ ctx, input }) => settingsService.saveOpenNetworkSettings(ctx.userId, input)),
|
||||||
loginDandanplay: adminProcedure
|
loginDandanplay: protectedProcedure
|
||||||
.input(settingsSchemas.loginDandanplay)
|
.input(settingsSchemas.loginDandanplay)
|
||||||
.mutation(({ ctx, input }) =>
|
.mutation(({ ctx, input }) =>
|
||||||
settingsService.loginDandanplay(ctx.userId, input.username, input.password),
|
settingsService.loginDandanplay(ctx.userId, input.username, input.password),
|
||||||
),
|
),
|
||||||
logoutDandanplay: adminProcedure
|
logoutDandanplay: protectedProcedure
|
||||||
.input(settingsSchemas.logoutDandanplay)
|
.input(settingsSchemas.logoutDandanplay)
|
||||||
.mutation(({ ctx }) => settingsService.logoutDandanplay(ctx.userId)),
|
.mutation(({ ctx }) => settingsService.logoutDandanplay(ctx.userId)),
|
||||||
dandanplayAuthStatus: adminProcedure
|
dandanplayAuthStatus: protectedProcedure
|
||||||
.input(settingsSchemas.logoutDandanplay)
|
.input(settingsSchemas.logoutDandanplay)
|
||||||
.query(() => settingsService.getDandanplayAuthStatus()),
|
.query(() => settingsService.getDandanplayAuthStatus()),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { createWebdav } from "./webdav-client.js";
|
||||||
|
|
||||||
vi.mock("./settings.service.js", () => ({
|
vi.mock("./settings.service.js", () => ({
|
||||||
ensureOpenRuntime: vi.fn(async () => {}),
|
ensureOpenRuntime: vi.fn(async () => {}),
|
||||||
|
getDandanplayAuthStatus: vi.fn(async () => ({ loggedIn: true, screenName: "u" })),
|
||||||
settingsService: {},
|
settingsService: {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -205,7 +206,7 @@ describe("danmakuService.fetch", () => {
|
||||||
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
||||||
vi.mocked(danmakuDocsDao.get).mockResolvedValue(docRow({ xml: "<i>local</i>" }));
|
vi.mocked(danmakuDocsDao.get).mockResolvedValue(docRow({ xml: "<i>local</i>" }));
|
||||||
const r = await danmakuService.fetch("u1", "m1");
|
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(danmakuDocsDao.get).toHaveBeenCalledWith("u1", "m1", "local-xml");
|
||||||
expect(openMatch).not.toHaveBeenCalled();
|
expect(openMatch).not.toHaveBeenCalled();
|
||||||
expect(openCommentXml).not.toHaveBeenCalled();
|
expect(openCommentXml).not.toHaveBeenCalled();
|
||||||
|
|
@ -228,7 +229,7 @@ describe("danmakuService.fetch", () => {
|
||||||
cacheRow({ payload: "<i>cached</i>" }),
|
cacheRow({ payload: "<i>cached</i>" }),
|
||||||
);
|
);
|
||||||
const r = await danmakuService.fetch("u1", "m1");
|
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(danmakuCacheDao.getValid).toHaveBeenCalledWith("77");
|
||||||
expect(openCommentXml).not.toHaveBeenCalled();
|
expect(openCommentXml).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
@ -239,7 +240,7 @@ describe("danmakuService.fetch", () => {
|
||||||
vi.mocked(hasOpenCredentials).mockReturnValue(false);
|
vi.mocked(hasOpenCredentials).mockReturnValue(false);
|
||||||
const r = await danmakuService.fetch("u1", "m1");
|
const r = await danmakuService.fetch("u1", "m1");
|
||||||
expect(r.source).toBe("none");
|
expect(r.source).toBe("none");
|
||||||
expect(r.message).toContain("账户设置");
|
expect(r.message).toContain("设置 → 开放弹幕网络");
|
||||||
expect(openMatch).not.toHaveBeenCalled();
|
expect(openMatch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -264,7 +265,7 @@ describe("danmakuService.fetch", () => {
|
||||||
});
|
});
|
||||||
vi.mocked(openCommentXml).mockResolvedValue("<i>open</i>");
|
vi.mocked(openCommentXml).mockResolvedValue("<i>open</i>");
|
||||||
const r = await danmakuService.fetch("u1", "m1");
|
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", {
|
expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalledWith("m1", "u1", {
|
||||||
episodeId: 555,
|
episodeId: 555,
|
||||||
matchedHash: null,
|
matchedHash: null,
|
||||||
|
|
@ -313,7 +314,7 @@ describe("danmakuService.selectMatch", () => {
|
||||||
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem({ matchedHash: "abc" }));
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem({ matchedHash: "abc" }));
|
||||||
vi.mocked(openCommentXml).mockResolvedValue("<i>open</i>");
|
vi.mocked(openCommentXml).mockResolvedValue("<i>open</i>");
|
||||||
const r = await danmakuService.selectMatch("u1", "m1", 4242);
|
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", {
|
expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalledWith("m1", "u1", {
|
||||||
episodeId: 4242,
|
episodeId: 4242,
|
||||||
matchedHash: "abc",
|
matchedHash: "abc",
|
||||||
|
|
@ -642,14 +643,14 @@ describe("danmakuService.fetch 同名侧车", () => {
|
||||||
vi.mocked(hasOpenCredentials).mockReturnValue(false);
|
vi.mocked(hasOpenCredentials).mockReturnValue(false);
|
||||||
const r = await danmakuService.fetch("u1", "m1");
|
const r = await danmakuService.fetch("u1", "m1");
|
||||||
expect(r.source).toBe("none");
|
expect(r.source).toBe("none");
|
||||||
expect(r.message).toContain("账户设置");
|
expect(r.message).toContain("设置 → 开放弹幕网络");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("已有 local-xml 时不扫描侧车", async () => {
|
it("已有 local-xml 时不扫描侧车", async () => {
|
||||||
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
||||||
vi.mocked(danmakuDocsDao.get).mockResolvedValue(docRow({ xml: "<i>local</i>" }));
|
vi.mocked(danmakuDocsDao.get).mockResolvedValue(docRow({ xml: "<i>local</i>" }));
|
||||||
const r = await danmakuService.fetch("u1", "m1");
|
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();
|
expect(createWebdav).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import type {
|
||||||
DanmakuSourceSearchOutput,
|
DanmakuSourceSearchOutput,
|
||||||
} from "@app/types";
|
} from "@app/types";
|
||||||
import { decryptSecret } from "./secret.js";
|
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";
|
import { createWebdav, joinWebdavPath, listDirectory, type DirEntry } from "./webdav-client.js";
|
||||||
|
|
||||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
@ -139,10 +139,20 @@ export const danmakuService = {
|
||||||
async fetch(userId: string, mediaItemId: string): Promise<DanmakuFetchOutput> {
|
async fetch(userId: string, mediaItemId: string): Promise<DanmakuFetchOutput> {
|
||||||
await ensureOpenRuntime();
|
await ensureOpenRuntime();
|
||||||
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
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");
|
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 返回
|
// 同目录同名 .xml 侧车自动关联;命中即按 local-xml 返回
|
||||||
if (await danmakuService.ensureSidecarXml(userId, mediaItemId)) {
|
if (await danmakuService.ensureSidecarXml(userId, mediaItemId)) {
|
||||||
|
|
@ -151,6 +161,7 @@ export const danmakuService = {
|
||||||
ok: true,
|
ok: true,
|
||||||
source: "local-xml",
|
source: "local-xml",
|
||||||
xml: linked?.xml ?? "",
|
xml: linked?.xml ?? "",
|
||||||
|
reason: "local-xml",
|
||||||
message: "同名弹幕",
|
message: "同名弹幕",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -160,7 +171,8 @@ export const danmakuService = {
|
||||||
ok: true,
|
ok: true,
|
||||||
source: "none",
|
source: "none",
|
||||||
xml: "",
|
xml: "",
|
||||||
message: "未配置开放弹幕网络:管理员请在「账户设置」页填写 AppId / AppSecret",
|
reason: "no-app-cred",
|
||||||
|
message: "未配置开放弹幕网络:请在「设置 → 开放弹幕网络」填写 AppId / AppSecret",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,7 +181,22 @@ export const danmakuService = {
|
||||||
? String(item.dandanplayEpisodeId)
|
? String(item.dandanplayEpisodeId)
|
||||||
: matchKeyFor(item.path, item.size);
|
: matchKeyFor(item.path, item.size);
|
||||||
const cached = await danmakuCacheDao.getValid(cacheKey);
|
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;
|
let episodeId = item.dandanplayEpisodeId ?? null;
|
||||||
if (episodeId == null) {
|
if (episodeId == null) {
|
||||||
|
|
@ -187,6 +214,7 @@ export const danmakuService = {
|
||||||
ok: true,
|
ok: true,
|
||||||
source: "none",
|
source: "none",
|
||||||
xml: "",
|
xml: "",
|
||||||
|
reason: "no-match",
|
||||||
message: outcome.errorMessage ?? "开放网络未匹配到弹幕",
|
message: outcome.errorMessage ?? "开放网络未匹配到弹幕",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -196,12 +224,15 @@ export const danmakuService = {
|
||||||
ok: true,
|
ok: true,
|
||||||
source: "none",
|
source: "none",
|
||||||
xml: "",
|
xml: "",
|
||||||
|
reason: "manual-select",
|
||||||
message: "匹配不明确,请在播放页手选弹幕集",
|
message: "匹配不明确,请在播放页手选弹幕集",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
episodeId = ranked.candidates[0]?.episodeId ?? null;
|
episodeId = ranked.candidates[0]?.episodeId ?? null;
|
||||||
if (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, {
|
await mediaItemDao.setDanmakuMatch(mediaItemId, userId, {
|
||||||
episodeId,
|
episodeId,
|
||||||
|
|
@ -212,11 +243,17 @@ export const danmakuService = {
|
||||||
|
|
||||||
const xml = await openCommentXml(episodeId);
|
const xml = await openCommentXml(episodeId);
|
||||||
if (xml === null) {
|
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 persistOpenXml(userId, mediaItemId, xml);
|
||||||
await persistOpenCache(episodeId, 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> {
|
async matchCandidates(userId: string, mediaItemId: string): Promise<DanmakuMatchOutput> {
|
||||||
|
|
@ -235,7 +272,7 @@ export const danmakuService = {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
matched: false,
|
matched: false,
|
||||||
message: "未配置开放弹幕网络",
|
message: "未配置开放弹幕网络:请在「设置 → 开放弹幕网络」配置",
|
||||||
candidates: [],
|
candidates: [],
|
||||||
highConfidence: false,
|
highConfidence: false,
|
||||||
};
|
};
|
||||||
|
|
@ -265,7 +302,15 @@ export const danmakuService = {
|
||||||
): Promise<DanmakuFetchOutput> {
|
): Promise<DanmakuFetchOutput> {
|
||||||
await ensureOpenRuntime();
|
await ensureOpenRuntime();
|
||||||
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
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, {
|
await mediaItemDao.setDanmakuMatch(mediaItemId, userId, {
|
||||||
episodeId,
|
episodeId,
|
||||||
matchedHash: item.matchedHash,
|
matchedHash: item.matchedHash,
|
||||||
|
|
@ -275,11 +320,25 @@ export const danmakuService = {
|
||||||
await danmakuDocsDao.remove(userId, mediaItemId, "local-xml");
|
await danmakuDocsDao.remove(userId, mediaItemId, "local-xml");
|
||||||
const xml = await openCommentXml(episodeId);
|
const xml = await openCommentXml(episodeId);
|
||||||
if (xml === null) {
|
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 persistOpenXml(userId, mediaItemId, xml);
|
||||||
await persistOpenCache(episodeId, 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(
|
async importXml(
|
||||||
|
|
@ -310,7 +369,7 @@ export const danmakuService = {
|
||||||
async searchSource(keyword: string): Promise<DanmakuSourceSearchOutput> {
|
async searchSource(keyword: string): Promise<DanmakuSourceSearchOutput> {
|
||||||
await ensureOpenRuntime();
|
await ensureOpenRuntime();
|
||||||
if (!hasOpenCredentials()) {
|
if (!hasOpenCredentials()) {
|
||||||
return { ok: false, message: "未配置开放弹幕网络", animes: [] };
|
return { ok: false, message: "未配置开放弹幕网络:请在「设置 → 开放弹幕网络」配置", animes: [] };
|
||||||
}
|
}
|
||||||
const outcome = await openSearchEpisodes(keyword);
|
const outcome = await openSearchEpisodes(keyword);
|
||||||
if (!outcome.ok) {
|
if (!outcome.ok) {
|
||||||
|
|
@ -334,7 +393,7 @@ export const danmakuService = {
|
||||||
if (!hasOpenCredentials()) {
|
if (!hasOpenCredentials()) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: "未配置开放弹幕网络",
|
message: "未配置开放弹幕网络:请在「设置 → 开放弹幕网络」配置",
|
||||||
animeId,
|
animeId,
|
||||||
animeTitle: null,
|
animeTitle: null,
|
||||||
episodes: [],
|
episodes: [],
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,8 @@ export const danmakuSchemas = {
|
||||||
.refine((v) => v.includes("<d "), "不是有效的弹幕 XML"),
|
.refine((v) => v.includes("<d "), "不是有效的弹幕 XML"),
|
||||||
fileName: z.string().max(255).optional(),
|
fileName: z.string().max(255).optional(),
|
||||||
}),
|
}),
|
||||||
|
/** 清除该条目的本地弹幕文档 */
|
||||||
|
clearLocal: z.object({ mediaItemId: id }),
|
||||||
settings: z.object({
|
settings: z.object({
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
opacity: z.number().min(0).max(1).default(0.8),
|
opacity: z.number().min(0).max(1).default(0.8),
|
||||||
|
|
@ -250,11 +252,24 @@ export type BatchResult = {
|
||||||
skipped: number;
|
skipped: number;
|
||||||
message: string;
|
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 = {
|
export type DanmakuFetchOutput = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
source: "open-network" | "cache" | "local-xml" | "none";
|
source: "open-network" | "cache" | "local-xml" | "none";
|
||||||
/** Bilibili/弹弹play 兼容 XML 字符串;无弹幕则为 "" */
|
/** Bilibili/弹弹play 兼容 XML 字符串;无弹幕则为 "" */
|
||||||
xml: string;
|
xml: string;
|
||||||
|
/** 机器可读原因,UI 据此给出路(配置/登录/手选) */
|
||||||
|
reason?: DanmakuFetchReason | undefined;
|
||||||
message?: string | undefined;
|
message?: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue