From 2bcf9f02301140ebf218bb911dda5e81362895d9 Mon Sep 17 00:00:00 2001 From: noelorin Date: Sun, 27 Sep 2026 11:19:31 +0800 Subject: [PATCH] feat(web,danmaku): danmaku side list and manual source picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-side danmaku list with click-to-seek; rematch dialog gains hierarchical source pick (search title → episode) via DanDanPlay open API. --- app/web/src/components/LocaleSwitch.tsx | 55 +++ app/web/src/components/pages/WatchBody.tsx | 404 ++++++++++++++---- app/web/src/layouts/AppLayout.astro | 43 +- app/web/src/views/auth.astro | 5 +- app/web/src/views/browse.astro | 5 +- app/web/src/views/danmaku.astro | 5 +- app/web/src/views/error.astro | 5 +- app/web/src/views/home.astro | 6 +- app/web/src/views/library.astro | 5 +- app/web/src/views/mounts.astro | 5 +- app/web/src/views/watch.astro | 5 +- e2e/i18n.spec.ts | 12 +- packages/danmaku/src/client.ts | 89 ++++ packages/danmaku/src/index.ts | 10 +- packages/i18n/src/messages/en.ts | 12 + packages/i18n/src/messages/zh-CN.ts | 12 + packages/trpc/src/router/media.router.ts | 6 + packages/trpc/src/services/danmaku.service.ts | 65 ++- packages/types/src/media-schemas.ts | 38 ++ 19 files changed, 635 insertions(+), 152 deletions(-) create mode 100644 app/web/src/components/LocaleSwitch.tsx diff --git a/app/web/src/components/LocaleSwitch.tsx b/app/web/src/components/LocaleSwitch.tsx new file mode 100644 index 0000000..da863e4 --- /dev/null +++ b/app/web/src/components/LocaleSwitch.tsx @@ -0,0 +1,55 @@ +/** + * 顶栏语言下拉:trigger 显示当前语言,菜单项来自 `LOCALES`(后续扩展只需改 `@app/i18n`)。 + * 菜单项是带 `data-switch-locale` 的 ``,由 AppLayout 内联脚本写 `app-lang` cookie 后跳转。 + */ + +import { LOCALES, type Locale, t } from "@app/i18n"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@app/ui"; + +export interface LocaleSwitchProps { + locale: Locale; + /** 各语言在当前页的 URL(AppLayout 按 pathname 预先算好) */ + hrefs: Record; +} + +export default function LocaleSwitch({ locale, hrefs }: LocaleSwitchProps) { + return ( + + + + + + {LOCALES.map((code) => ( + + + {t(code, `locale.${code}`)} + {code === locale && ( + + )} + + + ))} + + + ); +} diff --git a/app/web/src/components/pages/WatchBody.tsx b/app/web/src/components/pages/WatchBody.tsx index 52dd80e..221352c 100644 --- a/app/web/src/components/pages/WatchBody.tsx +++ b/app/web/src/components/pages/WatchBody.tsx @@ -35,6 +35,13 @@ function danmukuPlugin(art: Artplayer): DanmukuPlugin | null { return dm as DanmukuPlugin; } +function formatTime(sec: number): string { + const s = Math.max(0, Math.floor(sec)); + const m = Math.floor(s / 60); + const r = s % 60; + return `${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}`; +} + type ProgressInput = { mediaItemId: string; positionMs: number; @@ -134,6 +141,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { const playerRef = useRef(null); const artRef = useRef(null); const progressStartRef = useRef(0); + const [currentTime, setCurrentTime] = useState(0); const prefsRef = useRef(readDanmakuPrefs()); const [localXml, setLocalXml] = useState(""); /** 手选绑定后的临时展示(不写 local 覆盖,避免遮住服务端后续更新) */ @@ -154,6 +162,9 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { setMatchOpen(false); setPlayError(false); setEnded(false); + setSourceKeyword(""); + setSourceQuery(""); + setSourceAnimeId(null); }, [mediaItemId]); const detail = trpc.media.libraryGet.useQuery({ id: mediaItemId }); @@ -164,6 +175,18 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { const importXml = trpc.media.danmakuImport.useMutation(); const matchMut = trpc.media.danmakuMatch.useMutation(); const selectMut = trpc.media.danmakuSelectMatch.useMutation(); + // 手动指定弹幕源:作品 → 集数 + const [sourceKeyword, setSourceKeyword] = useState(""); + const [sourceQuery, setSourceQuery] = useState(""); + const [sourceAnimeId, setSourceAnimeId] = useState(null); + const sourceSearch = trpc.media.danmakuSearchSource.useQuery( + { keyword: sourceQuery }, + { enabled: matchOpen && sourceQuery.trim().length > 0 }, + ); + const sourceEpisodes = trpc.media.danmakuSourceEpisodes.useQuery( + { animeId: sourceAnimeId ?? 0 }, + { enabled: matchOpen && sourceAnimeId != null }, + ); const reportRef = useRef(report); useEffect(() => { reportRef.current = report; @@ -299,6 +322,11 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { }; art.on("video:loadedmetadata", seekToStart); + const onTimeUpdate = () => { + setCurrentTime(art.currentTime || 0); + }; + art.on("video:timeupdate", onTimeUpdate); + const snapshot = (): ProgressInput | null => { if (!art.duration) return null; return { @@ -371,7 +399,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { }, [progress.data]); const xmlSource = danmakuOn ? localXml || matchXml || openDanmaku.data?.xml || "" : ""; - const items = useMemo(() => { + const listComments = useMemo(() => { const raw = xmlSource ? parseDanmakuXml(xmlSource) : []; raw.sort((a, b) => a.time - b.time); const filters: FilterOptions | null = filterDraft @@ -382,9 +410,11 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { blockTypes: prefs.data.blockTypes, } : null; - const blocked = filters ? filterComments(raw, filters) : raw; - return toDanmukuItems(thinDanmaku(blocked, danmakuDensity)); - }, [xmlSource, prefs.data, filterDraft, danmakuDensity]); + return filters ? filterComments(raw, filters) : raw; + }, [xmlSource, prefs.data, filterDraft]); + const items = useMemo(() => { + return toDanmukuItems(thinDanmaku(listComments, danmakuDensity)); + }, [listComments, danmakuDensity]); const danmakuCount = items.length; // 弹幕数据变化时交给官方插件渲染 @@ -641,42 +671,95 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { -
-
- {playError && ( -
-

- {t(locale, "watch.playFailed")} -

-

- {t(locale, "watch.playFailedHint")} -

-
- - -
+
+
+
+
+ {playError && ( +
+

+ {t(locale, "watch.playFailed")} +

+

+ {t(locale, "watch.playFailedHint")} +

+
+ + +
+
+ )}
- )} -
-
- - {sourceLabel} - {isLocalSource && fetchMessage.includes("同名") && ( - - {t(locale, "watch.sidecarLinked")} +
+ + {sourceLabel} + {isLocalSource && fetchMessage.includes("同名") && ( + + {t(locale, "watch.sidecarLinked")} + + )} + {showRematch && ( + + )} - )} - {showRematch && ( - - )} - - {t(locale, "watch.progressHint")} + {t(locale, "watch.progressHint")} +
+
+ +
@@ -751,7 +834,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { {matchOpen && (
-
+

{t(locale, "watch.matchCandidates")} @@ -765,56 +848,203 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { ×

- {matchMut.data?.highConfidence && ( -

- {t(locale, "watch.matchAutoBound")} -

- )} - {matchMut.isPending && ( -

- {t(locale, "watch.loading")} -

- )} - {matchMut.isError && ( -

- {t(locale, "watch.matchFailed")} -

- )} - {matchMut.isSuccess && candidates.length === 0 && ( -

- {t(locale, "watch.matchEmpty")} -

- )} - {candidates.length > 0 && ( -
    - {candidates.map((c) => ( -
  • - +
  • + ))} +
+ )} +
+ +
+

{t(locale, "watch.manualPick")}

+

+ {t(locale, "watch.manualPickHint")} +

+
+ setSourceKeyword(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + setSourceAnimeId(null); + setSourceQuery(sourceKeyword.trim()); + } + }} + /> + +
+ + {sourceSearch.isPending && ( +

+ {t(locale, "watch.loading")} +

+ )} + {sourceSearch.isError && ( +

+ {t(locale, "watch.searchFailed")} +

+ )} + {sourceSearch.isSuccess && + sourceQuery && + sourceSearch.data.animes.length === 0 && ( +

+ {t(locale, "watch.searchEmpty")} +

+ )} + {sourceAnimeId == null && + (sourceSearch.data?.animes.length ?? 0) > 0 && ( +
    + {(sourceSearch.data?.animes ?? []).map((a) => ( +
  • + +
  • + ))} +
+ )} + + {sourceAnimeId != null && ( +
+
+ + + {sourceEpisodes.data?.animeTitle ?? + `#${sourceAnimeId}`} - - {c.episodeTitle ?? - t(locale, "watch.episodeN", { n: c.episodeId })} - - - episodeId {c.episodeId} - - {sameNameEpisodes.has(c.episodeId) && ( - - {t(locale, "watch.foundSameName")} - +
+ {sourceEpisodes.isPending && ( +

+ {t(locale, "watch.loading")} +

+ )} + {sourceEpisodes.isError && ( +

+ {t(locale, "watch.searchFailed")} +

+ )} + {sourceEpisodes.isSuccess && + sourceEpisodes.data.episodes.length === 0 && ( +

+ {t(locale, "watch.searchEmpty")} +

)} - - - ))} - - )} + {sourceEpisodes.data && + sourceEpisodes.data.episodes.length > 0 && ( +
    + {sourceEpisodes.data.episodes.map((ep) => ( +
  • + +
  • + ))} +
+ )} +
+ )} +
+
)} diff --git a/app/web/src/layouts/AppLayout.astro b/app/web/src/layouts/AppLayout.astro index 42d2f9b..06cff09 100644 --- a/app/web/src/layouts/AppLayout.astro +++ b/app/web/src/layouts/AppLayout.astro @@ -3,6 +3,7 @@ import { ClientRouter } from "astro:transitions"; import "../styles/globals.css"; import UserMenu from "~/components/UserMenu"; import ThemeSwitch from "~/components/ThemeSwitch"; +import LocaleSwitch from "~/components/LocaleSwitch"; import { buildDefaultJsonLd, buildHeadTags, @@ -10,7 +11,15 @@ import { toJsonLdScript, type PageSeoInput, } from "@app/seo-geo"; -import { COOKIE_LANG, COOKIE_LANG_MAX_AGE, type Locale, localePath, t } from "@app/i18n"; +import { + alternatePath, + COOKIE_LANG, + COOKIE_LANG_MAX_AGE, + LOCALES, + type Locale, + localePath, + t, +} from "@app/i18n"; import { getSiteSeo } from "~/lib/seo"; export interface Props { @@ -19,23 +28,17 @@ export interface Props { brand?: string; /** 当前页,用于侧栏高亮 */ active?: "home" | "library" | "browse" | "mounts" | "danmaku"; - switchHref?: string; - switchLabel?: string; seo?: PageSeoInput; /** 播放页隐藏模板页脚 */ hideFooter?: boolean; } -const { - title, - locale, - brand, - active, - switchHref, - switchLabel, - seo, - hideFooter = false, -} = Astro.props; +const { title, locale, brand, active, seo, hideFooter = false } = Astro.props; + +/** 语言下拉:各语言在当前页的 URL */ +const localeHrefs = Object.fromEntries( + LOCALES.map((code) => [code, alternatePath(Astro.url.pathname, code)]), +) as Record; const brandName = brand ?? t(locale, "meta.brand"); @@ -211,19 +214,7 @@ const nav = [ }
- { - switchHref && ( - - {switchLabel ?? (locale === "zh-CN" ? "EN" : "中文")} - - ) - } + diff --git a/app/web/src/views/auth.astro b/app/web/src/views/auth.astro index 8a4342a..4649576 100644 --- a/app/web/src/views/auth.astro +++ b/app/web/src/views/auth.astro @@ -1,5 +1,5 @@ --- -import { alternatePath, type Locale, t } from "@app/i18n"; +import { type Locale, t } from "@app/i18n"; import AuthBody from "~/components/pages/AuthBody"; import AppLayout from "~/layouts/AppLayout.astro"; @@ -11,7 +11,6 @@ export interface Props { } const { locale, mode, next, token } = Astro.props; -const other: Locale = locale === "zh-CN" ? "en" : "zh-CN"; const titleKey = mode === "login" @@ -30,8 +29,6 @@ const pageTitle = `${t(locale, titleKey as never)} · ${t(locale, "meta.brand")} diff --git a/app/web/src/views/browse.astro b/app/web/src/views/browse.astro index 629dca4..8a40e8b 100644 --- a/app/web/src/views/browse.astro +++ b/app/web/src/views/browse.astro @@ -1,5 +1,5 @@ --- -import { alternatePath, type Locale, t } from "@app/i18n"; +import { type Locale, t } from "@app/i18n"; import { Toaster } from "@app/ui"; import { BrowseBody } from "~/components/pages/BrowseBody"; import AppLayout from "~/layouts/AppLayout.astro"; @@ -9,7 +9,6 @@ export interface Props { } const { locale } = Astro.props; -const other: Locale = locale === "zh-CN" ? "en" : "zh-CN"; --- diff --git a/app/web/src/views/library.astro b/app/web/src/views/library.astro index 25a7e3b..f51ff3f 100644 --- a/app/web/src/views/library.astro +++ b/app/web/src/views/library.astro @@ -1,5 +1,5 @@ --- -import { alternatePath, type Locale, t } from "@app/i18n"; +import { type Locale, t } from "@app/i18n"; import { Toaster } from "@app/ui"; import { LibraryBody } from "~/components/pages/LibraryBody"; import AppLayout from "~/layouts/AppLayout.astro"; @@ -9,7 +9,6 @@ export interface Props { } const { locale } = Astro.props; -const other: Locale = locale === "zh-CN" ? "en" : "zh-CN"; --- { }); }); -test("header 语言切换链接跳到另一语言", async ({ page }) => { +test("header 语言下拉显示当前语言并可切换", async ({ page }) => { await page.goto("/zh-CN"); - const switchLink = page.locator('[data-switch-locale="en"]'); - await expect(switchLink).toBeVisible(); - await switchLink.click(); + const trigger = page.getByRole("button", { name: "语言" }); + await expect(trigger).toBeVisible(); + await expect(trigger).toContainText("中文"); + await trigger.click(); + const item = page.locator('[data-switch-locale="en"]'); + await expect(item).toBeVisible(); + await item.click(); await expect(page).toHaveURL(/\/en$/); }); diff --git a/packages/danmaku/src/client.ts b/packages/danmaku/src/client.ts index c8cedad..0f1b47b 100644 --- a/packages/danmaku/src/client.ts +++ b/packages/danmaku/src/client.ts @@ -95,3 +95,92 @@ export async function openCommentXml(episodeId: number): Promise const body = res.json as { comments?: Array> }; return openCommentsToXml(body.comments ?? []); } + +export type OpenAnimeBrief = { + animeId: number; + animeTitle: string | null; + type: string | null; + typeDescription: string | null; + imageUrl: string | null; + episodes: OpenEpisodeBrief[]; +}; + +export type OpenEpisodeBrief = { + episodeId: number; + episodeTitle: string | null; + type: string | null; + typeDescription: string | null; +}; + +type RawEpisode = { + episodeId?: number | string; + episodeTitle?: string | null; + type?: string | null; + typeDescription?: string | null; +}; + +type RawAnime = { + animeId?: number | string; + animeTitle?: string | null; + type?: string | null; + typeDescription?: string | null; + imageUrl?: string | null; + episodes?: RawEpisode[]; +}; + +function num(v: number | string | undefined): number | null { + const n = typeof v === "number" ? v : Number.parseInt(String(v ?? ""), 10); + return Number.isFinite(n) ? n : null; +} + +function parseEpisode(e: RawEpisode): OpenEpisodeBrief | null { + const episodeId = num(e.episodeId); + if (episodeId == null) return null; + return { + episodeId, + episodeTitle: e.episodeTitle ?? null, + type: e.type ?? null, + typeDescription: e.typeDescription ?? null, + }; +} + +function parseAnime(a: RawAnime): OpenAnimeBrief | null { + const animeId = num(a.animeId); + if (animeId == null) return null; + return { + animeId, + animeTitle: a.animeTitle ?? null, + type: a.type ?? null, + typeDescription: a.typeDescription ?? null, + imageUrl: a.imageUrl ?? null, + episodes: (a.episodes ?? []).map(parseEpisode).filter((x): x is OpenEpisodeBrief => x != null), + }; +} + +export type OpenSearchOutcome = + | { ok: true; animes: OpenAnimeBrief[] } + | { ok: false; errorMessage: string; animes: [] }; + +/** GET /api/v2/search/episodes?keyword= — 按关键词搜作品(含集列表) */ +export async function openSearchEpisodes(keyword: string): Promise { + const q = encodeURIComponent(keyword.trim()); + const res = await openFetchJson(`/api/v2/search/episodes?keyword=${q}`); + if (!res.ok) return { ok: false, errorMessage: res.error, animes: [] }; + const body = res.json as { animes?: RawAnime[]; errorCode?: number }; + const animes = (body.animes ?? []) + .map(parseAnime) + .filter((x): x is OpenAnimeBrief => x != null); + return { ok: true, animes }; +} + +export type OpenEpisodesOutcome = + | { ok: true; anime: OpenAnimeBrief | null } + | { ok: false; errorMessage: string; anime: null }; + +/** GET /api/v2/episodes/{animeId} — 作品下的集列表 */ +export async function openAnimeEpisodes(animeId: number): Promise { + const res = await openFetchJson(`/api/v2/episodes/${animeId}`); + if (!res.ok) return { ok: false, errorMessage: res.error, anime: null }; + const body = res.json as RawAnime; + return { ok: true, anime: parseAnime(body) }; +} diff --git a/packages/danmaku/src/index.ts b/packages/danmaku/src/index.ts index 59cfa9b..91ffd31 100644 --- a/packages/danmaku/src/index.ts +++ b/packages/danmaku/src/index.ts @@ -8,11 +8,19 @@ export type { OpenMatchOutcome, RankHint } from "./match.js"; export { hasOpenCredentials, openApiBase, + openAnimeEpisodes, openCommentXml, openFetchJson, openHeaders, openMatch, + openSearchEpisodes, readOpenCredentials, } from "./client.js"; -export type { OpenCredentials } from "./client.js"; +export type { + OpenAnimeBrief, + OpenCredentials, + OpenEpisodeBrief, + OpenEpisodesOutcome, + OpenSearchOutcome, +} from "./client.js"; export type { DanmakuComment, MatchCandidate } from "./types.js"; diff --git a/packages/i18n/src/messages/en.ts b/packages/i18n/src/messages/en.ts index cf3cd18..89c698e 100644 --- a/packages/i18n/src/messages/en.ts +++ b/packages/i18n/src/messages/en.ts @@ -215,6 +215,18 @@ export const en: Record = { "watch.blockKeywordAdd": "Add block word", "watch.matchFailed": "Danmaku match failed", "watch.matchEmpty": "No episode candidates found", + "watch.danmakuList": "Danmaku list", + "watch.danmakuListEmpty": "No danmaku yet", + "watch.clickToSeek": "Click to seek", + "watch.fileMatch": "Match by file", + "watch.manualPick": "Pick danmaku source", + "watch.manualPickHint": "Steps: search title → pick episode → fetch that episode (DanDanPlay open network)", + "watch.sourceSearch": "Search", + "watch.sourceSearchPlaceholder": "Search by title keyword", + "watch.sourceBackToAnime": "Back to titles", + "watch.searchFailed": "Search failed", + "watch.searchEmpty": "No results", + "watch.episodeCount": "{count} eps", "watch.blockKeywordPlaceholder": "Type a block word and press Enter", "danmaku.title": "Danmaku settings", diff --git a/packages/i18n/src/messages/zh-CN.ts b/packages/i18n/src/messages/zh-CN.ts index 62f0547..c0ebf41 100644 --- a/packages/i18n/src/messages/zh-CN.ts +++ b/packages/i18n/src/messages/zh-CN.ts @@ -210,6 +210,18 @@ export const zhCN = { "watch.blockKeywordAdd": "添加屏蔽词", "watch.matchFailed": "弹幕匹配失败", "watch.matchEmpty": "未找到可选手集", + "watch.danmakuList": "弹幕列表", + "watch.danmakuListEmpty": "暂无弹幕", + "watch.clickToSeek": "点击跳转到该时间点", + "watch.fileMatch": "按文件匹配", + "watch.manualPick": "手动指定弹幕源", + "watch.manualPickHint": "层级:搜索作品 → 选择集数 → 取该集弹幕(弹弹play 开放网络)", + "watch.sourceSearch": "搜索", + "watch.sourceSearchPlaceholder": "输入作品名关键词", + "watch.sourceBackToAnime": "返回作品列表", + "watch.searchFailed": "搜索失败", + "watch.searchEmpty": "没有找到结果", + "watch.episodeCount": "{count} 集", "watch.blockKeywordPlaceholder": "输入屏蔽词后回车", "danmaku.title": "弹幕设置", diff --git a/packages/trpc/src/router/media.router.ts b/packages/trpc/src/router/media.router.ts index 394f700..37aca45 100644 --- a/packages/trpc/src/router/media.router.ts +++ b/packages/trpc/src/router/media.router.ts @@ -137,6 +137,12 @@ export const mediaRouter = t.router({ .mutation(({ ctx, input }) => danmakuService.selectMatch(ctx.userId, input.mediaItemId, input.episodeId), ), + danmakuSearchSource: protectedProcedure + .input(danmakuSchemas.searchSource) + .query(({ input }) => danmakuService.searchSource(input.keyword)), + danmakuSourceEpisodes: protectedProcedure + .input(danmakuSchemas.sourceEpisodes) + .query(({ input }) => danmakuService.sourceEpisodes(input.animeId)), danmakuImport: protectedProcedure .input(danmakuSchemas.import) .mutation(({ ctx, input }) => diff --git a/packages/trpc/src/services/danmaku.service.ts b/packages/trpc/src/services/danmaku.service.ts index 0d2a5cd..324971e 100644 --- a/packages/trpc/src/services/danmaku.service.ts +++ b/packages/trpc/src/services/danmaku.service.ts @@ -6,11 +6,19 @@ import { hasOpenCredentials, headMd5, matchFileName, + openAnimeEpisodes, openCommentXml, openMatch, + openSearchEpisodes, rankCandidates, } from "@app/danmaku"; -import type { DanmakuFetchOutput, DanmakuMatchOutput, DanmakuSettingsOutput } from "@app/types"; +import type { + DanmakuFetchOutput, + DanmakuMatchOutput, + DanmakuSettingsOutput, + DanmakuSourceEpisodesOutput, + DanmakuSourceSearchOutput, +} from "@app/types"; import { decryptSecret } from "./secret.js"; import { createWebdav, joinWebdavPath, listDirectory, type DirEntry } from "./webdav-client.js"; @@ -295,6 +303,61 @@ export const danmakuService = { return { ok: true, count: (xml.match(/ { + if (!hasOpenCredentials()) { + return { ok: false, message: "未配置开放弹幕网络", animes: [] }; + } + const outcome = await openSearchEpisodes(keyword); + if (!outcome.ok) { + return { ok: false, message: outcome.errorMessage, animes: [] }; + } + return { + ok: true, + animes: outcome.animes.map((a) => ({ + animeId: a.animeId, + animeTitle: a.animeTitle, + typeDescription: a.typeDescription, + imageUrl: a.imageUrl, + episodeCount: a.episodes.length, + })), + }; + }, + + /** 手动指定弹幕源:列作品集数 */ + async sourceEpisodes(animeId: number): Promise { + if (!hasOpenCredentials()) { + return { + ok: false, + message: "未配置开放弹幕网络", + animeId, + animeTitle: null, + episodes: [], + }; + } + const outcome = await openAnimeEpisodes(animeId); + if (!outcome.ok) { + return { + ok: false, + message: outcome.errorMessage, + animeId, + animeTitle: null, + episodes: [], + }; + } + const anime = outcome.anime; + return { + ok: true, + animeId, + animeTitle: anime?.animeTitle ?? null, + episodes: (anime?.episodes ?? []).map((e) => ({ + episodeId: e.episodeId, + episodeTitle: e.episodeTitle, + typeDescription: e.typeDescription, + })), + }; + }, + async getPrefs(userId: string): Promise { const row = await danmakuPrefsDao.getByUser(userId); return { diff --git a/packages/types/src/media-schemas.ts b/packages/types/src/media-schemas.ts index 14baa68..ec41f8d 100644 --- a/packages/types/src/media-schemas.ts +++ b/packages/types/src/media-schemas.ts @@ -204,6 +204,14 @@ export const danmakuSchemas = { mediaItemId: id, episodeId: z.number().int().positive(), }), + /** 手动搜作品(弹弹play 开放网络) */ + searchSource: z.object({ + keyword: z.string().min(1, "请输入关键词").max(200), + }), + /** 手动列作品集数 */ + sourceEpisodes: z.object({ + animeId: z.number().int().positive(), + }), /** 导入本地 XML 正文(服务端持久化);粗检含弹幕