diff --git a/README.md b/README.md
index 004e712..5ea44cd 100644
--- a/README.md
+++ b/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
+# 然后走上面「首次运行配置」
+```
diff --git a/app/web/src/components/pages/SettingsAdminBody.tsx b/app/web/src/components/pages/SettingsAdminBody.tsx
index 321debf..cf8e663 100644
--- a/app/web/src/components/pages/SettingsAdminBody.tsx
+++ b/app/web/src/components/pages/SettingsAdminBody.tsx
@@ -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
{t(locale, "watch.loading")}
;
}
- if (!isAdmin) {
+ if (!isAdmin && !me.data?.user) {
return null;
}
@@ -110,6 +110,7 @@ function Inner(props: { locale: Locale }) {
{label("settings.ddpLogin")}
{label("settings.ddpLoginHint")}
+
{label("settings.sharedAccountHint")}
{ddpStatus.data?.loggedIn ? (
diff --git a/app/web/src/components/pages/WatchBody.tsx b/app/web/src/components/pages/WatchBody.tsx
index c904542..c87bfc9 100644
--- a/app/web/src/components/pages/WatchBody.tsx
+++ b/app/web/src/components/pages/WatchBody.tsx
@@ -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 (
@@ -707,6 +714,40 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
{t(locale, "watch.rematch")}
)}
+ {(openDanmaku.data?.reason === "need-login" ||
+ openDanmaku.data?.reason === "no-app-cred") && (
+
+ )}
+ {isLocalSource && (
+
+ )}
{t(locale, "watch.progressHint")}
diff --git a/packages/i18n/src/messages/en.ts b/packages/i18n/src/messages/en.ts
index bf6086b..5cb5447 100644
--- a/packages/i18n/src/messages/en.ts
+++ b/packages/i18n/src/messages/en.ts
@@ -353,4 +353,10 @@ export const en: Record = {
"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.",
};
diff --git a/packages/i18n/src/messages/zh-CN.ts b/packages/i18n/src/messages/zh-CN.ts
index 322e1f8..8b11664 100644
--- a/packages/i18n/src/messages/zh-CN.ts
+++ b/packages/i18n/src/messages/zh-CN.ts
@@ -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;
diff --git a/packages/trpc/src/router/media.router.ts b/packages/trpc/src/router/media.router.ts
index 37aca45..90cbbf5 100644
--- a/packages/trpc/src/router/media.router.ts
+++ b/packages/trpc/src/router/media.router.ts
@@ -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 }) =>
diff --git a/packages/trpc/src/router/settings.router.ts b/packages/trpc/src/router/settings.router.ts
index ff48324..a20e4a6 100644
--- a/packages/trpc/src/router/settings.router.ts
+++ b/packages/trpc/src/router/settings.router.ts
@@ -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()),
});
diff --git a/packages/trpc/src/services/danmaku.service.test.ts b/packages/trpc/src/services/danmaku.service.test.ts
index 4039a5d..b4dc535 100644
--- a/packages/trpc/src/services/danmaku.service.test.ts
+++ b/packages/trpc/src/services/danmaku.service.test.ts
@@ -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: "local" }));
const r = await danmakuService.fetch("u1", "m1");
- expect(r).toEqual({ ok: true, source: "local-xml", xml: "local" });
+ expect(r).toMatchObject({ ok: true, source: "local-xml", xml: "local" });
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: "cached" }),
);
const r = await danmakuService.fetch("u1", "m1");
- expect(r).toEqual({ ok: true, source: "cache", xml: "cached" });
+ expect(r).toMatchObject({ ok: true, source: "cache", xml: "cached" });
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("open");
const r = await danmakuService.fetch("u1", "m1");
- expect(r).toEqual({ ok: true, source: "open-network", xml: "open" });
+ expect(r).toMatchObject({ ok: true, source: "open-network", xml: "open" });
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("open");
const r = await danmakuService.selectMatch("u1", "m1", 4242);
- expect(r).toEqual({ ok: true, source: "open-network", xml: "open" });
+ expect(r).toMatchObject({ ok: true, source: "open-network", xml: "open" });
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: "local" }));
const r = await danmakuService.fetch("u1", "m1");
- expect(r).toEqual({ ok: true, source: "local-xml", xml: "local" });
+ expect(r).toMatchObject({ ok: true, source: "local-xml", xml: "local" });
expect(createWebdav).not.toHaveBeenCalled();
});
});
diff --git a/packages/trpc/src/services/danmaku.service.ts b/packages/trpc/src/services/danmaku.service.ts
index f932537..19e8faf 100644
--- a/packages/trpc/src/services/danmaku.service.ts
+++ b/packages/trpc/src/services/danmaku.service.ts
@@ -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 {
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 {
@@ -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 {
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 {
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: [],
diff --git a/packages/types/src/media-schemas.ts b/packages/types/src/media-schemas.ts
index ec41f8d..96979d6 100644
--- a/packages/types/src/media-schemas.ts
+++ b/packages/types/src/media-schemas.ts
@@ -222,6 +222,8 @@ export const danmakuSchemas = {
.refine((v) => v.includes("