diff --git a/.env.example b/.env.example index 316c94b..52946f3 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,10 @@ TURSO_DATABASE_URL=file:local.db # 媒体挂载凭据加密密钥:64 位 hex(openssl rand -hex 32) MEDIA_SECRET_KEY= +# 找回密码令牌交付:配置后以 POST JSON { email, username, token, resetPath } 推送到该地址 +# (可对接邮件/企业微信/ bark 等任意通知服务);未配置时令牌写入服务日志 +PASSWORD_RESET_WEBHOOK_URL= + # 弹弹play 开放弹幕网络(https://doc.dandanplay.com/open) # 未配置 AppId/AppSecret 时,弹幕接口返回 source=none「未配置开放网络」 OPEN_DANMAKU_APP_ID= diff --git a/.gitignore b/.gitignore index 0e0717a..c059d0d 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,13 @@ storybook-static/ .superpowers/ .worktrees/ worktrees/ +playwright-report/ +test-results/ + +# vitepress cache +app/docs/.vitepress/cache/ +# local screenshots / fixtures +ui-audit/ +tmp-e2e-dav/ +generated-*.png +tmp-*.png diff --git a/AGENTS.md b/AGENTS.md index f14aa1a..86ad5a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,10 @@ -# AGENTS.md — app-template 总览 +# AGENTS.md — 帆幕(Fanmu)总览 面向 AI 编码代理的仓库导览。修改任何代码前,先读本文件与目标子包的 `AGENTS.md`。 ## 项目是什么 -**可复用全栈脚手架 / 开发模板**(不绑定产品业务)。只保留鉴权 + `user` 垂直示例,演示完整分层接线。视觉基线见 `DESIGN.md`。 +**帆幕(Fanmu)**:私有影音库 · 弹幕观影 · 网盘挂载。本仓库为**产品仓**,业务代码进入既有分层。视觉基线见 `DESIGN.md`。 技术栈:Astro 7(SSR)+ React 19 islands · tRPC v11 · Drizzle ORM + Turso/libSQL · Tailwind CSS 4 · TypeScript 7 · Biome · Yarn 4 workspaces + Turbo。 @@ -15,7 +15,7 @@ | `@app/types` | Zod 入参 schema + 共享输出类型 | zod | | `@app/models` | Drizzle 表定义(唯一 schema 源) | drizzle-orm | | `@app/db` | libSQL/Turso 客户端单例 + drizzle-kit | models | -| `@app/dao` | 数据访问层(`userDao` 等) | db, models | +| `@app/dao` | 数据访问层(`userDao` / `mountDao` / `mediaItemDao` 等) | db, models | | `@app/trpc` | tRPC 路由 + service 业务逻辑(调用 DAO) | types, dao | | `@app/web` | Astro 前端 + React islands + 本地 tRPC HTTP 适配 | trpc, types, design-tokens, seo-geo, i18n, ui | | `@app/ui` | shadcn 风格基础 UI 组件(源码直出 + Storybook) | design-tokens, react (peer) | @@ -23,6 +23,7 @@ | `@app/seo-geo` | SEO/GEO 纯函数(meta、sitemap、JSON-D、llms.txt) | zod, schema-dts, escape-html, fast-xml-builder, serialize-javascript, mdast-util-to-markdown | | `@app/i18n` | 文案与 locale 纯函数(zh-CN / en、t()、Accept-Language) | — | | `@app/adsense` | AdSense 脚本加载 + `AdSlot` React 组件 | react (peer) | +| `@app/docs` | 产品文档站(VitePress,中文根路径 + 英文 `/en`) | —(仅 vitepress) | 依赖方向(只允许向下): @@ -35,6 +36,8 @@ web ──► trpc ──► dao ──► db ──► models ├──► i18n ├──► adsense └──► ui + +docs(独立,不依赖上述业务包) ``` 禁止循环依赖。跨包只通过 `package.json` 的 `exports` 入口引用,不要 deep import。 @@ -43,8 +46,10 @@ web ──► trpc ──► dao ──► db ──► models - 源码在各包 `src/`;构建产物在 `dist/`(git 忽略,勿手改)。 - 环境变量模板见 `.env.example`:`TURSO_DATABASE_URL`、`TURSO_AUTH_TOKEN`、`PUBLIC_SITE_URL`。 -- 根 `.env` 由 `packages/db/drizzle.config.ts` 手动加载(drizzle-kit 在 monorepo 中不会自动读根 env)。 -- **模板不掺杂业务逻辑**:禁止把产品领域实体/页面写进本仓库;下游业务仓库再扩展。 +- 根 `.env` 由 `packages/db/drizzle.config.ts` 与 `packages/db/scripts/seed.ts` 手动加载(monorepo 下工具不会自动读根 env)。 +- **分层不跳层**:业务实体按 types → models → dao → trpc → web 落地;领域逻辑进 service,SQL 进 dao。 +- `e2e/`:Playwright 端到端测试(配置在根 `playwright.config.ts`),不是 workspace 包。 +- `app/docs/`:VitePress 文档站源码(`.vitepress/` 配置 + `guide/` 中文 + `en/guide/` 英文)。 ## 常用命令 @@ -56,9 +61,14 @@ yarn build # 全仓 build(依赖拓扑) yarn build:web # 仅 @app/web + 上游依赖闭包(日常/CI 更快) yarn dev:web # Astro :4321 yarn dev:api # 独立 tRPC :4000(web 内嵌 /api/trpc 时通常不需要) +yarn docs:dev # VitePress 文档站 +yarn docs:build +yarn test:e2e # Playwright(自动起 webServer) yarn db:generate # Drizzle 迁移生成 yarn db:push # 推 schema +yarn db:seed # 初始化默认管理员(幂等) yarn db:studio +yarn storybook # @app/ui Storybook :6006 ``` 改某个包时可先 `yarn workspace @app/ typecheck`。 @@ -73,7 +83,7 @@ yarn db:studio 6. **UI**:设计令牌从 `@app/design-tokens` 引入;组件优先用 `@app/ui`(shadcn 风格,源码在 `packages/ui`)+ `cn()`。视觉遵循 `DESIGN.md`。 7. **构建顺序**:改 `types`/`models` 后需先 build 上游,再让下游 typecheck(Turbo `dependsOn: ["^build"]` 已配置)。 8. **注释**:只写非显而易见的 WHY;默认不写注释。 -9. **新增业务实体**:复制 `user` 垂直切片(types → models → dao → trpc → web),不要跳层。 +9. **新增业务实体**:按 types → models → dao → trpc → web 分层落地,不要跳层。 ## 各包入口 @@ -90,3 +100,4 @@ yarn db:studio - [packages/i18n/AGENTS.md](packages/i18n/AGENTS.md) - [packages/adsense/AGENTS.md](packages/adsense/AGENTS.md) - [packages/ui/AGENTS.md](packages/ui/AGENTS.md) +- [app/docs/AGENTS.md](app/docs/AGENTS.md) diff --git a/DESIGN.md b/DESIGN.md index 4c6d51f..1b4d4e6 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,4 +1,4 @@ -# 弹弹 Web — 设计方向 +# 帆幕(Fanmu)— 设计方向 产品视觉基线:**shadcn/ui Dashboard**(媒体五页侧栏/顶栏导航、卡片网格、中性灰阶);模板深空工业 token 仍可用于非媒体页,但 **`@app/design-tokens` 色板 token 路径约定不变**(改值可以,改结构需同步全局)。 @@ -16,7 +16,7 @@ | 墨色 | `#F2F2F2` | | 弱化文字 | `#999999` | | 更弱文字 | `#666666` | -| 强调(信号黄) | `#FFFA00` | +| 强调(品牌金黄) | `#FFB300` | | 信号绿 | `#00FFA2` | | 信号品红 | `#FF1AAC` | | 亮色背景(对照) | `#FAFAFA` | diff --git a/Dockerfile b/Dockerfile index b08391c..f0e26c0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,8 @@ RUN npm install -g corepack && corepack enable FROM base AS deps COPY package.json yarn.lock .yarnrc.yml ./ COPY app/web/package.json app/web/package.json -# workspaces: packages/* — 清单缺一不可,否则 yarn 解析 workspace:* 失败或漏链 +# workspaces: app/* + packages/* — 清单缺一不可,否则 yarn 解析 workspace:* 失败或漏链 +COPY app/docs/package.json app/docs/package.json COPY packages/adsense/package.json packages/adsense/package.json COPY packages/dao/package.json packages/dao/package.json COPY packages/db/package.json packages/db/package.json diff --git a/README.md b/README.md index 3bf913e..004e712 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# 弹弹 Web — 本地片源 · 在线弹幕 +# 帆幕 — 私有影音库 · 弹幕观影 · 网盘挂载 -多用户 Web 媒体中心:WebDAV/OpenList 挂载、媒体库扫描与 Bangumi 刮削、ArtPlayer 播放与弹幕(开放网络 + 本地 XML)。 +**帆幕(Fanmu)** 是自建私有影音库:WebDAV/OpenList 挂载片源、媒体库扫描与 Bangumi 刮削、ArtPlayer 播放与弹幕(开放网络 + 本地 XML),多用户进度与权限。 基于 monorepo 分层(types → models → db → dao → trpc → web),本仓库按**产品仓**维护。 @@ -49,4 +49,4 @@ yarn dev:web # Astro :4321 `web → trpc → dao → db → models`,横切 `types`。禁止循环依赖;跨包只走 `package.json` 的 `exports`。 -新增业务实体请复制 `user` 垂直切片:`types` schema → `models` 表 → `dao` 查询 → `trpc` service/router → `web` 页面。细节见根目录 `AGENTS.md`。 +新增业务实体请按既有分层接线:`types` schema → `models` 表 → `dao` 查询 → `trpc` service/router → `web` 页面。细节见根目录 `AGENTS.md`。 diff --git a/app/docs/.vitepress/config.mts b/app/docs/.vitepress/config.mts new file mode 100644 index 0000000..3db6187 --- /dev/null +++ b/app/docs/.vitepress/config.mts @@ -0,0 +1,160 @@ +import { defineConfig } from "vitepress"; + +const zhNav = [ + { text: "是什么", link: "/guide/what-is" }, + { text: "功能", link: "/guide/library" }, + { text: "上手", link: "/guide/quick-start" }, + { text: "问题", link: "/guide/faq" }, +]; + +const zhSidebar = [ + { + text: "了解帆幕", + items: [ + { text: "帆幕是什么", link: "/guide/what-is" }, + { text: "快速上手", link: "/guide/quick-start" }, + ], + }, + { + text: "怎么用", + items: [ + { text: "连网盘", link: "/guide/mount" }, + { text: "找视频", link: "/guide/browse" }, + { text: "媒体库", link: "/guide/library" }, + { text: "看片和弹幕", link: "/guide/danmaku" }, + { text: "认出是哪部剧", link: "/guide/scrape" }, + { text: "账号和进度", link: "/guide/account" }, + ], + }, + { + text: "帮助", + items: [{ text: "常见问题", link: "/guide/faq" }], + }, +]; + +const enNav = [ + { text: "About", link: "/en/guide/what-is" }, + { text: "Features", link: "/en/guide/library" }, + { text: "Get started", link: "/en/guide/quick-start" }, + { text: "FAQ", link: "/en/guide/faq" }, +]; + +const enSidebar = [ + { + text: "About Fanmu", + items: [ + { text: "What is Fanmu", link: "/en/guide/what-is" }, + { text: "Get started", link: "/en/guide/quick-start" }, + ], + }, + { + text: "How to use", + items: [ + { text: "Mount a drive", link: "/en/guide/mount" }, + { text: "Find a video", link: "/en/guide/browse" }, + { text: "Library", link: "/en/guide/library" }, + { text: "Watch and danmaku", link: "/en/guide/danmaku" }, + { text: "Identify the show", link: "/en/guide/scrape" }, + { text: "Accounts and progress", link: "/en/guide/account" }, + ], + }, + { + text: "Help", + items: [{ text: "FAQ", link: "/en/guide/faq" }], + }, +]; + +// 文档站:VitePress;产品说明文档,中文在根路径,英文在 /en +export default defineConfig({ + cleanUrls: true, + lastUpdated: true, + head: [["link", { rel: "icon", href: "/favicon.svg" }]], + locales: { + root: { + label: "简体中文", + lang: "zh-CN", + link: "/", + title: "帆幕文档", + description: "帆幕 — 自己的影音库,看片有弹幕。挂网盘、播视频、飘弹幕。", + themeConfig: { + nav: zhNav, + sidebar: zhSidebar, + outline: { level: [2, 3], label: "本页目录" }, + search: { + provider: "local", + options: { + translations: { + button: { buttonText: "搜索", buttonAriaLabel: "搜索文档" }, + modal: { + noResultsText: "未找到结果", + resetButtonTitle: "清除查询", + footer: { + selectText: "选择", + navigateText: "切换", + closeText: "关闭", + }, + }, + }, + }, + }, + docFooter: { prev: "上一篇", next: "下一篇" }, + darkModeSwitchLabel: "主题", + sidebarMenuLabel: "菜单", + returnToTopLabel: "回到顶部", + langMenuLabel: "切换语言", + lastUpdatedText: "更新于", + notFound: { + title: "页面不存在", + quote: "这里什么都没有", + linkLabel: "回首页", + linkText: "回首页", + }, + }, + }, + en: { + label: "English", + lang: "en", + link: "/en/", + title: "Fanmu Docs", + description: + "Fanmu — your own media library with danmaku. Mount a drive, play video, watch comments fly.", + themeConfig: { + nav: enNav, + sidebar: enSidebar, + outline: { level: [2, 3], label: "On this page" }, + search: { + provider: "local", + options: { + translations: { + button: { buttonText: "Search", buttonAriaLabel: "Search docs" }, + modal: { + noResultsText: "No results", + resetButtonTitle: "Clear query", + footer: { + selectText: "Select", + navigateText: "Navigate", + closeText: "Close", + }, + }, + }, + }, + }, + docFooter: { prev: "Previous", next: "Next" }, + darkModeSwitchLabel: "Theme", + sidebarMenuLabel: "Menu", + returnToTopLabel: "Back to top", + langMenuLabel: "Change language", + lastUpdatedText: "Updated", + notFound: { + title: "Page not found", + quote: "Nothing lives at this address", + linkLabel: "Go home", + linkText: "Go home", + }, + }, + }, + }, + markdown: { + lineNumbers: true, + }, +}); diff --git a/app/docs/.vitepress/theme/custom.css b/app/docs/.vitepress/theme/custom.css new file mode 100644 index 0000000..a4539a4 --- /dev/null +++ b/app/docs/.vitepress/theme/custom.css @@ -0,0 +1,11 @@ +/** + * VitePress 主题:使用默认品牌色与明暗切换。 + * 仅保留排版微调,不覆盖 --vp-c-* 品牌语义变量。 + */ + +:root { + --vp-font-family-base: + "Space Grotesk", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; + --vp-font-family-mono: + "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} diff --git a/app/docs/.vitepress/theme/index.ts b/app/docs/.vitepress/theme/index.ts new file mode 100644 index 0000000..f7b7805 --- /dev/null +++ b/app/docs/.vitepress/theme/index.ts @@ -0,0 +1,18 @@ +import DefaultTheme from "vitepress/theme"; +import type { Theme } from "vitepress"; +// @ts-expect-error vitepress 会处理 css 导入 +import "./custom.css"; + +/** 无本地偏好时默认暗色(与主应用一致),仍可手动切换亮/暗。 */ +const initTheme = `try { const k='vitepress-theme-appearance'; if(!localStorage.getItem(k)) localStorage.setItem(k,'dark'); } catch {}`; + +export default { + extends: DefaultTheme, + enhanceApp() { + if (typeof document !== "undefined") { + const s = document.createElement("script"); + s.textContent = initTheme; + document.head.prepend(s); + } + }, +} satisfies Theme; diff --git a/app/docs/AGENTS.md b/app/docs/AGENTS.md new file mode 100644 index 0000000..d357a79 --- /dev/null +++ b/app/docs/AGENTS.md @@ -0,0 +1,48 @@ +# AGENTS.md — @app/docs + +## 职责 + +**产品文档站**(VitePress):面向用户的使用说明,中文在根路径、英文在 `/en`。与 `@app/web` 产品页分离——这里是文档,不是应用路由。 + +不依赖 `@app/*` 业务包;纯 Markdown + VitePress 配置,无 tRPC / DB。 + +## 结构 + +``` +.vitepress/ + config.mts # locales(root=zh-CN, en)、nav、sidebar、搜索文案 + theme/index.ts # 扩展默认主题;无本地偏好时默认暗色(与主应用一致) + theme/custom.css # 自定义样式 +guide/ # 中文文档(权威源) + what-is.md | quick-start.md | mount.md | browse.md + library.md | danmaku.md | scrape.md | account.md | faq.md +en/guide/ # 英文文档(与 guide/ 同构,逐篇对应) +index.md # 中文首页 +en/index.md # 英文首页 +public/ # 静态资源(favicon 等) +``` + +## 约定 + +- **双语同构**:`guide/*.md` 与 `en/guide/*.md` 文件名一一对应;新增页面两边同时加,并在 `config.mts` 的 `zhSidebar` / `enSidebar` 登记。 +- 路径用 `cleanUrls: true`(链接不带 `.html`)。 +- 文案语气与产品一致(「帆幕」/「Fanmu」);中文文档用简体中文,不混繁体。 +- 搜索为 VitePress local provider,UI 文案在 `config.mts` 的 `themeConfig.search` 内联翻译,不另建 i18n 包。 +- 主题默认暗色:`theme/index.ts` 在无 `vitepress-theme-appearance` 时写入 `dark`,与 web 的 `app-theme` 偏好独立。 + +## 修改指南 + +- **加一页文档**:`guide/.md` + `en/guide/.md` → 更新 `config.mts` 两侧 sidebar。 +- **改导航顺序**:只改 `config.mts` 的 `zhNav`/`zhSidebar`/`enNav`/`enSidebar`,保持中英结构对称。 +- **加静态资源**:放 `public/`,引用用绝对路径 `/xxx`。 +- 不要引入 Vue 组件或自定义布局(当前只用默认主题 + CSS);确需扩展时走 `theme/index.ts`。 +- 构建产物 `.vitepress/dist/` 与缓存 `.vitepress/cache/` 勿手改。 + +## 命令 + +```bash +yarn docs:dev # 本地预览 +yarn docs:build # 构建到 .vitepress/dist/ +yarn workspace @app/docs typecheck +yarn workspace @app/docs lint +``` diff --git a/app/docs/en/guide/account.md b/app/docs/en/guide/account.md new file mode 100644 index 0000000..2f39021 --- /dev/null +++ b/app/docs/en/guide/account.md @@ -0,0 +1,31 @@ +# Accounts and progress + +Fanmu supports more than one person. + +## One account each + +- Register your own account +- Each person has their own library and watch progress +- Nobody sees the other's list + +The admin (from `db:seed`) can manage users. + +## Progress follows the person + +Zhang San is 12 minutes in. Li Si opens the same file and starts from zero. + +Switching devices is fine too: watch on the phone, continue on the desktop, same account. + +## Forgot password + +Use "Forgot password" on the login page. If mail is not configured, ask the admin to reset it. + +## Default account + +Local `db:seed` creates: + +| User | Password | +|------|----------| +| admin | admin123 | + +**That password is public. Change it before the app faces the internet.** diff --git a/app/docs/en/guide/browse.md b/app/docs/en/guide/browse.md new file mode 100644 index 0000000..3e09e3a --- /dev/null +++ b/app/docs/en/guide/browse.md @@ -0,0 +1,27 @@ +# Find a video + +Once the drive is mounted, just browse. + +## How + +1. Open **Browse** +2. Pick a mount +3. Open folders like a file manager + +Each file can be **played** directly or **added to the library**. + +## Cannot find anything? + +- **Empty folder**: check the [mount](/en/guide/mount) connection +- **Will not play**: browsers handle mp4 and webm best. mkv depends on the browser. Convert the file or try another browser +- **Ugly file names**: fine for playback. For posters and titles, [scan into the library](/en/guide/library) or [identify the show](/en/guide/scrape) + +## Browse vs library + +| | Browse | Library | +|--|--------|---------| +| What you see | Real folders on the drive | A tidy show list | +| Good for | Quick look | Regular shows, resume | +| Source | Mounted files | Scanned items | + +Both can play. Browse is the file view; library is the show view. diff --git a/app/docs/en/guide/danmaku.md b/app/docs/en/guide/danmaku.md new file mode 100644 index 0000000..c82fca4 --- /dev/null +++ b/app/docs/en/guide/danmaku.md @@ -0,0 +1,46 @@ +# Watch and danmaku + +Open a file and you are on the player page. + +## Playback + +It is a web player. Play/pause, seek, volume, fullscreen are all there. + +Leave halfway and progress is kept. + +## Where danmaku comes from + +Same idea as DanDanPlay: **one episode, one danmaku pool**. + +1. You open a video +2. The system knows which show and episode it is (or you identified it earlier) +3. It asks the danmaku network for that episode +4. Danmaku floats over the video + +So a 720P from one group and a 1080P from another, once matched to the same episode, share the same danmaku. + +## No danmaku? + +- **Unmatched**: [identify the show](/en/guide/scrape) first +- **Network down**: video still plays, just silent of danmaku. Retry later +- **Prefer a local file**: import an XML + +### Import XML danmaku + +On the player or the [Danmaku settings](#danmaku-settings) page, import a `.xml` danmaku file (the kind exported from Bilibili). + +A video and a same-named danmaku file in one folder is also picked up. + +## Danmaku settings + +The **Danmaku** page lets you set: + +- On or off by default +- Opacity (lower it if it blocks the picture) +- Density (how many at once on screen) + +You can also toggle while watching. + +## A note on content + +Danmaku comes from a public pool. If something bothers you, turn it off. Fanmu does not edit danmaku text. diff --git a/app/docs/en/guide/faq.md b/app/docs/en/guide/faq.md new file mode 100644 index 0000000..f222f2f --- /dev/null +++ b/app/docs/en/guide/faq.md @@ -0,0 +1,56 @@ +# FAQ + +## Playback + +**Black screen / will not play?** + +Browsers do not all handle mkv or ts. Try another browser, or convert to mp4. It may also be a broken mount — test the connection. + +**Seeking? Quality switching?** + +You can seek. There is no quality switch: the file on the drive is what plays. + +**Progress not saved?** + +A flaky network can drop progress reports. Drag the slider if a spot is missing. + +## Danmaku + +**None at all?** + +Work through this: + +1. Was the video identified? If not, [identify it](/en/guide/scrape) +2. Is the match correct? A wrong match pulls the wrong pool +3. Is the danmaku network up? +4. Still nothing? [Import XML](/en/guide/danmaku) + +**Too dense?** + +Lower opacity and density, or turn it off. + +**Can I send danmaku?** + +Yes. It goes to the public pool. + +## Mounts + +**Test connection fails?** + +- Use the WebDAV root, not a web page URL +- For OpenList, use the WebDAV endpoint +- Check username, password, and the HTTPS certificate + +**Why not SMB / Emby?** + +v1 is WebDAV only. More later if there is demand. + +## Accounts + +**Is email required?** + +Depends on your deployment. Locally, the seeded admin is enough. + +**Can I see what others watched?** + +No. Progress is private. diff --git a/app/docs/en/guide/library.md b/app/docs/en/guide/library.md new file mode 100644 index 0000000..e6de02b --- /dev/null +++ b/app/docs/en/guide/library.md @@ -0,0 +1,37 @@ +# Library + +Do not want to dig through folders every time? Scan shows into the library. + +## What it looks like + +A grid of posters with titles. Open one and you get the episode list. Watched episodes show progress; one click resumes. + +## How items get in + +1. **Add to library** on a file in Browse +2. **Scan** on a mount — takes every video under it + +A scan will: + +- Record the video files +- Guess show and episode from the name +- Look up title and poster on Bangumi +- Mark anything it cannot place as "unmatched" — it will not block playback + +## After the scan + +| You see | Meaning | +|---------|---------| +| Poster and title | Match worked | +| Unmatched | Name was unreadable, or Bangumi had no hit | +| Continue | Resume from saved progress | + +## Wrong match? + +Fix it in [Identify the show](/en/guide/scrape). + +## Progress + +Playback position is saved as you watch. Next time, continue. + +Progress is per person. Zhang San and Li Si do not share it. See [Accounts](/en/guide/account). diff --git a/app/docs/en/guide/mount.md b/app/docs/en/guide/mount.md new file mode 100644 index 0000000..7e9569b --- /dev/null +++ b/app/docs/en/guide/mount.md @@ -0,0 +1,41 @@ +# Mount a drive + +Videos on a cloud drive? Mount it into Fanmu first. + +## What is supported + +**WebDAV**. + +OpenList (formerly AList) works through its WebDAV endpoint — there is no separate OpenList API. + +SMB, Emby, and OneDrive are not supported yet. + +## How + +1. Open **Mounts** in the sidebar +2. Add a mount and fill in: + +| Field | Meaning | +|-------|---------| +| Name | Anything, e.g. "My NAS" | +| URL | WebDAV root, like `https://nas.example.com/dav` | +| Username | Drive account (optional) | +| Password | Drive password (optional) | +| Root path | Only look under this folder (empty = whole root) | + +3. Click **Test connection**, then save + +## Is the password safe + +The password is **encrypted on the server**. The browser never sees the plaintext. Streams also go through the server, so your browser does not talk to the drive with your password. + +## If it fails + +- Cannot connect: check that the URL is the WebDAV root, and that the account and HTTPS cert are fine +- Connects but empty: the root path may be too deep +- Do not need it anymore: disable or delete the mount + +## Next + +- [Find a video](/en/guide/browse) +- [Scan into the library](/en/guide/library) diff --git a/app/docs/en/guide/quick-start.md b/app/docs/en/guide/quick-start.md new file mode 100644 index 0000000..4e8857c --- /dev/null +++ b/app/docs/en/guide/quick-start.md @@ -0,0 +1,75 @@ +# Get started + +Goal: a running app and a login screen in a few minutes. + +## What you need + +- Node.js 22 or newer +- Yarn 4 (enable via Corepack) +- No database server; local SQLite is the default + +## Four steps + +### 1. Install + +```bash +corepack enable +yarn install +``` + +### 2. Config + +```bash +cp .env.example .env +``` + +Local defaults are fine. `TURSO_DATABASE_URL` already points at `file:local.db`. + +To mount a drive you also need a secret (used to encrypt saved drive passwords): + +```bash +openssl rand -hex 32 +``` + +Put it in `.env` as `MEDIA_SECRET_KEY`. + +### 3. Database and admin + +```bash +yarn db:push +yarn db:seed +``` + +`db:seed` creates **admin / admin123**. Change that password before going public. + +### 4. Run + +```bash +yarn dev:web +``` + +Open `http://localhost:4321`. English is at `/en`, Chinese at `/zh-CN`. + +After login you should see: + +- **Library** — your shows +- **Browse** — folders on the drive +- **Mounts** — drive connections +- **Danmaku** — on/off and style +- **Watch** — the player page + +## Next + +1. [Mount a drive](/en/guide/mount) +2. [Play something](/en/guide/browse) +3. [Scan into the library](/en/guide/library) + +## Commands + +| Command | Does | +|---------|------| +| `yarn dev:web` | Dev server on port 4321 | +| `yarn build:web` | Build the web app | +| `yarn docs:dev` | Preview these docs | +| `yarn typecheck` | Type check | +| `yarn lint` | Lint | diff --git a/app/docs/en/guide/scrape.md b/app/docs/en/guide/scrape.md new file mode 100644 index 0000000..13be821 --- /dev/null +++ b/app/docs/en/guide/scrape.md @@ -0,0 +1,43 @@ +# Identify the show + +Danmaku and posters both depend on knowing **what this is**. Get this right and the rest is easy. + +## Automatic + +On scan or add-to-library, the file name is used to guess: + +- Which show +- Which episode + +Then Fanmu looks it up on **Bangumi** for title, poster, and episode number. + +## If the guess is wrong + +The item is marked **unmatched**. + +Nothing is deleted. Playback still works. You can leave it, or bind it by hand. + +## Fix it by hand + +Open the item: + +1. Search for the show on Bangumi +2. Pick the right one +3. Confirm + +You can re-scrape after binding to refresh posters and metadata. + +## Why Bangumi + +Bangumi has solid anime data and a real API. Not Douban, not TMDB. + +If a niche title is missing there, you can only hand-edit the name, and danmaku may not match. Use [local XML danmaku](/en/guide/danmaku) as fallback. + +## Common cases + +| Case | What to do | +|------|------------| +| Name like `VCB-Studio][BanG Dream! Ave Mujica][01][1080p][x265_FLAC].mkv` | Usually auto-matched | +| Name like `video.mp4` | Search manually | +| Matched the wrong show | Rebind | +| OVA / special / movie | Episode number may be off; fix it | diff --git a/app/docs/en/guide/what-is.md b/app/docs/en/guide/what-is.md new file mode 100644 index 0000000..9b64e04 --- /dev/null +++ b/app/docs/en/guide/what-is.md @@ -0,0 +1,45 @@ +# What is Fanmu + +Fanmu is a **self-hosted media library**. + +Your videos live on a WebDAV drive (OpenList included). Mount that drive in Fanmu and you can play in a browser, with danmaku on top. + +## Why this exists + +Watching a local or cloud file has one old problem: **no danmaku**. + +DanDanPlay solved this for desktop: play a file from disk, get matching online danmaku. Fanmu does the same idea in a browser: + +- Nothing to install on every machine +- Phone, tablet, TV browser all work +- Files stay in one place on the drive + +## What it can do + +- **Mount WebDAV** (OpenList uses its WebDAV endpoint) +- **Play video** in the browser; progress is saved +- **Danmaku**: matched from the open danmaku network; local XML as fallback +- **Library**: scan in files, posters, resume where you left off +- **Accounts**: each person has their own list and progress + +## How it relates to DanDanPlay + +| | DanDanPlay | Fanmu | +|--|------------|-------| +| Where it runs | Desktop app | In the browser | +| Where files live | Local disk | WebDAV drive | +| Danmaku | Open danmaku network | Same network | +| Install client | Yes | No | + +Fanmu is not a plugin and does not need DanDanPlay running on your PC. It stands on its own: **video from your drive, danmaku from the web**. + +## Not in v1 + +Present in DanDanPlay, not here yet: + +- BT download / RSS +- AI subtitles and summaries +- Cast, frame interpolation +- Emby / Jellyfin / Plex (WebDAV only for now) + +The main path first: mount → find → play → danmaku → progress. diff --git a/app/docs/en/index.md b/app/docs/en/index.md new file mode 100644 index 0000000..ade036a --- /dev/null +++ b/app/docs/en/index.md @@ -0,0 +1,55 @@ +--- +layout: home + +hero: + name: Fanmu + text: Your own media library, with danmaku + tagline: Mount a cloud drive, play video in the browser, and get online danmaku on top. No desktop client needed. + actions: + - theme: brand + text: What it does + link: /en/guide/what-is + - theme: alt + text: Get started + link: /en/guide/quick-start + +features: + - title: Mount and play + details: Hook up a WebDAV drive and play right in the browser. The password stays on the server. + - title: Video with danmaku + details: Danmaku is matched automatically, the same way DanDanPlay does for local files. You can also import a local XML file. + - title: Knows what you are watching + details: Scans figure out the show and episode, then pull in posters and metadata. Fix a wrong match by hand if needed. + - title: Built for households + details: Multiple accounts. Everyone gets their own library and watch progress. +--- + +## What is this + +**Fanmu** is a media library you host yourself. + +Your videos live on a cloud drive (WebDAV, including OpenList's WebDAV endpoint). Mount it in Fanmu and play in the browser, with danmaku. + +The idea is close to DanDanPlay: local video plus online danmaku. Fanmu just runs in the browser, so you do not install a player on every machine. + +## What you can do + +| Task | Guide | +|------|-------| +| [Mount a drive](/en/guide/mount) | Connect WebDAV | +| [Find a video](/en/guide/browse) | Browse folders and play | +| [Library](/en/guide/library) | Scan in shows, posters, resume | +| [Watch and danmaku](/en/guide/danmaku) | Playback, danmaku, styling | +| [Identify the show](/en/guide/scrape) | Auto match, or fix it yourself | +| [Accounts](/en/guide/account) | Sign in, private progress | + +## Run it + +```bash +yarn install +cp .env.example .env +yarn db:push && yarn db:seed +yarn dev:web +``` + +Open `http://localhost:4321` — English lives at `/en`. More detail in [Get started](/en/guide/quick-start). diff --git a/app/docs/guide/account.md b/app/docs/guide/account.md new file mode 100644 index 0000000..62da3b1 --- /dev/null +++ b/app/docs/guide/account.md @@ -0,0 +1,31 @@ +# 账号和进度 + +帆幕支持多个人用。 + +## 一人一个账号 + +- 注册自己的账号 +- 各人有自己的媒体库、挂载(看部署怎么配)、观看进度 +- 互不干扰 + +管理员(`db:seed` 建的那个)可以管用户。 + +## 进度跟人走 + +张三看到第 12 分钟,李四打开同一个视频,还是从头开始。 + +换设备也一样:手机看一半,电脑上接着看(只要登的是同一个账号)。 + +## 忘了密码 + +登录页有「忘记密码」流程。如果邮件功能没配好,就让管理员帮你重置。 + +## 默认账号 + +本地开发 `db:seed` 会建: + +| 账号 | 密码 | +|------|------| +| admin | admin123 | + +**这个密码是公开的,开到公网前一定要改。** diff --git a/app/docs/guide/browse.md b/app/docs/guide/browse.md new file mode 100644 index 0000000..d36805b --- /dev/null +++ b/app/docs/guide/browse.md @@ -0,0 +1,27 @@ +# 找视频 + +挂好网盘之后,就可以翻文件夹了。 + +## 怎么找 + +1. 打开侧栏 **浏览** +2. 选一个挂载 +3. 一层层点进去,像用文件管理器一样 + +每行文件可以直接 **播放**,也可以 **加入媒体库**。 + +## 还是找不到? + +- **文件夹是空的**:先去[挂载](/guide/mount)看看连接通不通 +- **视频格式播不了**:浏览器认 mp4、webm 比较稳;mkv 有的浏览器能播有的不行。播不了的话,可以先把文件转一下,或者换个浏览器试试 +- **名字乱七八糟**:不影响播。想要好看的海报和标题,去[媒体库](/guide/library)扫描,或[手动认剧](/guide/scrape) + +## 和媒体库什么关系 + +| | 浏览 | 媒体库 | +|--|------|--------| +| 看的是什么 | 网盘真实文件夹 | 整理过的片单 | +| 适合 | 随便翻、临时看一眼 | 常看的剧、接着看 | +| 来源 | 挂载里的文件 | 扫描进来的 | + +两条路都能播。浏览是「文件视角」,媒体库是「剧集视角」。 diff --git a/app/docs/guide/danmaku.md b/app/docs/guide/danmaku.md new file mode 100644 index 0000000..3a9654b --- /dev/null +++ b/app/docs/guide/danmaku.md @@ -0,0 +1,48 @@ +# 看片和弹幕 + +点开一个视频,就是播放页。 + +## 播放 + +播放器是网页里的,不用装东西。基本操作(播放/暂停、进度、音量、全屏)都有。 + +看一半关掉不要紧,进度记住了,下次接着看。 + +## 弹幕从哪来 + +和弹弹play一个思路:**同一集,用同一份弹幕**。 + +流程是这样: + +1. 你打开一个视频 +2. 系统猜这是哪部剧第几集(或者你之前已经认过了) +3. 拿这个「身份」去弹幕网络要弹幕 +4. 弹幕飘在视频上 + +所以字幕组 A 的 720P 和字幕组 B 的 1080P,只要认成同一集,弹幕就是同一份。 + +## 没有弹幕怎么办 + +- **识别失败**:先去[认剧](/guide/scrape)手动认一下 +- **弹幕网络挂了**:照样能看视频,只是没弹幕。过会儿再试 +- **就是想要本地弹幕**:导入 XML 文件 + +### 导入 XML 弹幕 + +播放页或[弹幕设置](#弹幕设置)里可以导入 `.xml` 弹幕文件(B 站导出的那种)。 + +同名的视频和弹幕文件放在一个文件夹里,也认得出来。 + +## 弹幕设置 + +侧栏 **弹幕** 页面可以调: + +- 默认开还是关 +- 透明度(怕挡画面就调淡一点) +- 密度(一屏幕最多飘多少条) + +播放的时候也能随时开关。 + +## 弹幕礼仪 + +弹幕内容来自公开弹幕库。看到了不舒服的,关掉弹幕就好。帆幕不存储、不修改弹幕内容。 diff --git a/app/docs/guide/faq.md b/app/docs/guide/faq.md new file mode 100644 index 0000000..f58994f --- /dev/null +++ b/app/docs/guide/faq.md @@ -0,0 +1,66 @@ +# 常见问题 + +## 播放相关 + +**视频点开是黑的 / 播不了?** + +浏览器不一定认得 mkv、ts 这些格式。换个浏览器试试,或者把视频转成 mp4。也可能是网盘断了,去挂载页测一下连接。 + +**能快进吗?能选清晰度吗?** + +能快进。清晰度跟文件走——你网盘里是什么文件,就播什么,没有「切换清晰度」这一说。 + +**进度没记上?** + +网络不好的时候进度上报会失败。正常看一般没事;如果某次没记上,手动拖一下就好。 + +## 弹幕相关 + +**完全没有弹幕?** + +按这个顺序查: + +1. 这个视频认成剧了吗?没认成就[手动认](/guide/scrape) +2. 认对了吗?认错了弹幕就是别家的 +3. 弹幕网络通不通?挂了就等一会儿 +4. 实在没有,[导入 XML 弹幕](/guide/danmaku) + +**弹幕太多挡脸?** + +弹幕设置里调透明度和密度,或者直接关掉。 + +**能发弹幕吗?** + +能,发出去的是公共弹幕库,大家都能看到。 + +## 挂载相关 + +**测试连接失败?** + +- 地址要写 WebDAV 根,不是网页地址 +- OpenList 用它的 WebDAV 端点 +- 用户名密码要对 +- HTTPS 证书要有效 + +**为什么不是 SMB / Emby?** + +第一版只做 WebDAV。其他以后看需求。 + +## 账号相关 + +**注册要邮箱吗?** + +看部署怎么配的。本地开发用 seed 的管理员就行。 + +**能看到别人看了什么吗?** + +不能。各人进度各人自己看。 + +## 还有别的问题 + +到仓库提 issue,附上: + +- 你干了什么 +- 期望发生什么 +- 实际发生什么 +- 截图或报错文字 diff --git a/app/docs/guide/library.md b/app/docs/guide/library.md new file mode 100644 index 0000000..d9fb6e5 --- /dev/null +++ b/app/docs/guide/library.md @@ -0,0 +1,39 @@ +# 媒体库 + +不想每次都翻文件夹?把片子扫进媒体库,以后按剧找。 + +## 它长什么样 + +媒体库是一排海报卡片,写着剧名。点进去是这一部的分集列表,看过一半的会显示进度,点一下就能接着看。 + +## 怎么进来 + +两种方式: + +1. **浏览页里点「加入库」**:加单个文件 +2. **挂载页里点「扫描」**:把这个挂载下视频都扫一遍 + +扫描会做这些事: + +- 把视频文件记下来 +- 猜这是哪部剧第几集 +- 去 Bangumi 查剧名、海报 +- 查不到就标成「未识别」,不会丢,也不会挡住你播 + +## 扫完之后 + +| 你会看到 | 说明 | +|----------|------| +| 海报、标题 | 识别成功的话会自动填 | +| 未识别 | 文件名猜不出来,或 Bangumi 查不到 | +| 继续播放 | 有进度的条目,点进去接着看 | + +## 认错了怎么办 + +去[认剧](/guide/scrape)手动改绑。 + +## 进度怎么记 + +每看到一处,会自动记住看到哪了。下次打开同一个视频,接着来。 + +进度跟人走:张三和李四各记各的,互不影响。见[账号](/guide/account)。 diff --git a/app/docs/guide/mount.md b/app/docs/guide/mount.md new file mode 100644 index 0000000..c7684c0 --- /dev/null +++ b/app/docs/guide/mount.md @@ -0,0 +1,41 @@ +# 连网盘 + +想看的片子在网盘里?先把它「挂」到帆幕上。 + +## 目前支持什么 + +**WebDAV**。 + +OpenList(以前的 AList)也用它的 WebDAV 地址,不要去找什么「OpenList 专用接口」。 + +其他(SMB、Emby、OneDrive 直连)暂时不支持。 + +## 怎么挂 + +1. 打开侧栏 **挂载** +2. 点添加,填这些: + +| 填什么 | 说明 | +|--------|------| +| 名称 | 随便起,比如「我的NAS」 | +| 地址 | WebDAV 根地址,形如 `https://nas.example.com/dav` | +| 用户名 | 网盘账号(可空) | +| 密码 | 网盘密码(可空) | +| 根路径 | 只看这个目录下面(可空,表示整个根) | + +3. 先点 **测试连接**,通了再保存 + +## 密码安全吗 + +密码发到服务器后会**加密存起来**,浏览器拿不到明文。每次去网盘拉文件,也是服务器出面,你的浏览器不直接碰网盘密码。 + +## 挂错了怎么办 + +- 连不上:检查地址是不是 WebDAV 根,用户名密码对不对,HTTPS 证书有没有问题 +- 能连上但没文件:看看「根路径」是不是填太深了 +- 不想用了:挂载页里可以停用或删掉 + +## 下一步 + +- [在网盘里找视频](/guide/browse) +- [扫一遍进媒体库](/guide/library) diff --git a/app/docs/guide/quick-start.md b/app/docs/guide/quick-start.md new file mode 100644 index 0000000..4bdc9d0 --- /dev/null +++ b/app/docs/guide/quick-start.md @@ -0,0 +1,75 @@ +# 快速上手 + +目标:几分钟内跑起来,登录进去看到界面。 + +## 你需要什么 + +- Node.js 22 以上 +- Yarn 4(用 Corepack 打开就行) +- 不用装数据库软件,默认用本地 SQLite 文件 + +## 四步跑起来 + +### 1. 装依赖 + +```bash +corepack enable +yarn install +``` + +### 2. 准备配置 + +```bash +cp .env.example .env +``` + +本地开发一般不用改什么。`TURSO_DATABASE_URL` 默认就是 `file:local.db`,数据存在当前目录。 + +如果要挂网盘,还需要一个密钥(用来加密你保存的网盘密码): + +```bash +openssl rand -hex 32 +``` + +把结果填进 `.env` 的 `MEDIA_SECRET_KEY`。 + +### 3. 建库、建管理员 + +```bash +yarn db:push +yarn db:seed +``` + +`db:seed` 会建一个管理员:**admin / admin123**。上线前记得改密码。 + +### 4. 启动 + +```bash +yarn dev:web +``` + +打开 `http://localhost:4321`,走中文入口在 `/zh-CN`,英文在 `/en`。 + +登录后你应该能看到这些页面: + +- **媒体库**:你的片子 +- **浏览**:网盘里的文件夹 +- **挂载**:管理网盘连接 +- **弹幕**:弹幕开关和样式 +- **播放**:点开视频后的页面 + +## 接下来干什么 + +1. [把网盘挂上来](/guide/mount) +2. [找个视频播一下](/guide/browse) +3. 想要整齐的片单就[扫进媒体库](/guide/library) + +## 常用命令 + +| 命令 | 干什么 | +|------|--------| +| `yarn dev:web` | 开发模式跑主应用(4321 端口) | +| `yarn build:web` | 打包主应用 | +| `yarn docs:dev` | 本地预览这份文档 | +| `yarn typecheck` | 检查类型 | +| `yarn lint` | 检查代码风格 | diff --git a/app/docs/guide/scrape.md b/app/docs/guide/scrape.md new file mode 100644 index 0000000..b7c47bb --- /dev/null +++ b/app/docs/guide/scrape.md @@ -0,0 +1,46 @@ +# 认出是哪部剧 + +弹幕和海报,都靠「认出这是什么」。这一步做对了,后面都省事。 + +## 自动认 + +扫描或加入媒体库时,会看文件名,猜: + +- 这是哪部作品 +- 这是第几集 + +然后去 **Bangumi** 查资料,把标题、海报、剧集号填上。 + +## 猜不准会怎样 + +标成 **未识别**。 + +不会删文件,不会不让你播,只是先不配弹幕、不配海报。你可以: + +- 留着不管,直接播 +- 手动绑一下(见下) + +## 手动改 + +打开这个条目的详情: + +1. 搜索 Bangumi 上的剧名 +2. 选中正确的那一部 +3. 确认绑定 + +绑完可以再刮一次(重新拉海报和信息)。 + +## 为什么用 Bangumi + +Bangumi 的动画资料比较全,而且有 API。不是豆瓣,也不是 TMDB。 + +如果某部冷门作品 Bangumi 上没有,那就只能手动填名字,弹幕也匹配不到——这种情况下用[本地 XML 弹幕](/guide/danmaku)兜底。 + +## 常见情况 + +| 情况 | 该怎么办 | +|------|----------| +| 文件名是 `VCB-Studio][BanG Dream! Ave Mujica][01][1080p][x265_FLAC].mkv` | 一般能自动认出来 | +| 文件名是 `视频.mp4` | 认不出来,手动搜 | +| 认成了别的剧 | 手动改绑 | +| 特典 / OVA / 剧场版 | 集数可能对不上,手动调 | diff --git a/app/docs/guide/what-is.md b/app/docs/guide/what-is.md new file mode 100644 index 0000000..2f0d7a3 --- /dev/null +++ b/app/docs/guide/what-is.md @@ -0,0 +1,45 @@ +# 帆幕是什么 + +帆幕是一个**自己架的影音库**。 + +你的视频存在网盘里(比如 OpenList、支持 WebDAV 的网盘)。挂载到帆幕之后,打开浏览器就能播,而且能飘弹幕。 + +## 为什么要这个东西 + +看本地片或网盘片有个老问题:**没有弹幕**。 + +弹弹play 解决过这个问题——播放硬盘里的视频,自动配上网络弹幕。帆幕的想法差不多,只是跑在浏览器里: + +- 不用在每台电脑上装客户端 +- 手机、平板、电视浏览器都能开 +- 片子集中存在网盘,不用到处拷 + +## 能干什么 + +- **连网盘**:支持 WebDAV(OpenList 也走它的 WebDAV) +- **看视频**:浏览器里直接播,进度自动记住 +- **飘弹幕**:自动去弹幕库匹配;匹配不上就导入本地 XML 弹幕 +- **媒体库**:扫一遍入库,有海报、有剧集信息,能「继续看」 +- **多账号**:一人一个片单和进度 + +## 跟弹弹play 什么关系 + +| | 弹弹play | 帆幕 | +|--|----------|------| +| 在哪跑 | 电脑上的软件 | 浏览器网页 | +| 片子在哪 | 本机硬盘 | WebDAV 网盘 | +| 弹幕 | 在线弹幕库 | 同一套开放弹幕网络 | +| 装客户端 | 要 | 不用 | + +帆幕不是弹弹play 的插件,也不用先开着电脑上的弹弹play。它是独立的一套东西,能力对齐:**本地/网盘视频 + 在线弹幕**。 + +## 还没有的东西 + +第一版先不做这些(弹弹play 里有的): + +- BT 下载、RSS 追新 +- AI 字幕、AI 总结 +- 投屏、插帧 +- Emby / Jellyfin / Plex 直连(目前只认 WebDAV) + +先把「挂载 → 找片 → 播放 → 弹幕 → 进度」这条主路走顺。 diff --git a/app/docs/index.md b/app/docs/index.md new file mode 100644 index 0000000..8344f71 --- /dev/null +++ b/app/docs/index.md @@ -0,0 +1,53 @@ +--- +layout: home + +hero: + name: 帆幕 + text: 自己的影音库,看片有弹幕 + tagline: 挂上网盘播视频,自动配上网上弹幕。不用装客户端,打开浏览器就能看。 + actions: + - theme: brand + text: 看看能干什么 + link: /guide/what-is + - theme: alt + text: 马上用起来 + link: /guide/quick-start + +features: + - title: 连上网盘就看 + details: 把 WebDAV 网盘挂进来,在浏览器里直接播。账号密码留在服务器,不进浏览器。 + - title: 看片有弹幕 + details: 播视频时自动去找对应的弹幕,跟在弹弹play里看本地片一样。没有的话也能导入本地弹幕文件。 + - title: 认得出是哪部剧 + details: 扫一扫就知道这是哪部作品第几集,自动配上海报和信息。认错了也能手动改。 + - title: 一家人都能用 + details: 多账号,各人有各人的片单和进度,互不干扰。 +--- + +## 这是啥 + +**帆幕** 就是你自己架的一个影音库。 + +片子放在网盘里,用浏览器打开帆幕就能看,而且能飘弹幕。体验上参考了弹弹play:本地视频也能配上网上弹幕,不过帆幕整个跑在浏览器里,不用在电脑上装播放器。 + +## 能干啥 + +| 事情 | 说明 | +|------|------| +| [连网盘](/guide/mount) | 把 WebDAV 网盘挂上来 | +| [找视频](/guide/browse) | 在网盘里翻文件夹,点开就播 | +| [媒体库](/guide/library) | 扫一遍入库,有海报、能接着看 | +| [看片和弹幕](/guide/danmaku) | 播放、发弹幕、调弹幕样式 | +| [认剧](/guide/scrape) | 自动认出哪部剧第几集,认错能改 | +| [账号](/guide/account) | 注册登录,各人看各人的 | + +## 怎么跑起来 + +```bash +yarn install +cp .env.example .env +yarn db:push && yarn db:seed +yarn dev:web +``` + +浏览器打开 `http://localhost:4321`,用 `admin` / `admin123` 登录。更细的步骤见[快速上手](/guide/quick-start)。 diff --git a/app/docs/package.json b/app/docs/package.json new file mode 100644 index 0000000..fce1685 --- /dev/null +++ b/app/docs/package.json @@ -0,0 +1,18 @@ +{ + "name": "@app/docs", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vitepress dev", + "build": "vitepress build", + "preview": "vitepress preview", + "typecheck": "tsc --noEmit", + "lint": "biome check ." + }, + "devDependencies": { + "@biomejs/biome": "^2.1.1", + "typescript": "^7.0.2", + "vitepress": "^1.6.4" + } +} diff --git a/app/docs/public/favicon.svg b/app/docs/public/favicon.svg new file mode 100644 index 0000000..08bdf86 --- /dev/null +++ b/app/docs/public/favicon.svg @@ -0,0 +1,6 @@ + + App Template + + + + diff --git a/app/docs/tsconfig.json b/app/docs/tsconfig.json new file mode 100644 index 0000000..6e16be0 --- /dev/null +++ b/app/docs/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "lib": ["ES2022", "DOM"], + "types": ["node"], + "moduleResolution": "bundler", + "allowArbitraryExtensions": true + }, + "include": [".vitepress/**/*.ts", ".vitepress/**/*.mts"] +} diff --git a/app/web/AGENTS.md b/app/web/AGENTS.md index 5a096c4..bc594e7 100644 --- a/app/web/AGENTS.md +++ b/app/web/AGENTS.md @@ -2,14 +2,14 @@ ## 职责 -Astro SSR 前端(Node standalone):页面、布局、React islands、tRPC HTTP 适配、SEO 文本端点、**i18n 路由**。 +**薄 SSR** Astro 前端(Node standalone):布局 / head / SEO / 中间件服务端渲染;页面主体为 React 客户端组件(`client:load`)。另含 tRPC HTTP 适配、SEO 文本端点、**i18n 路由**。 -脚手架基线页面:首页、鉴权、仪表盘(user 列表示例)、设置。视觉遵循仓库根 `DESIGN.md` 与 `@app/design-tokens`。 +产品页面:首页、鉴权、仪表盘、媒体库 / 浏览 / 播放 / 挂载 / 弹幕设置与错误页。视觉遵循仓库根 `DESIGN.md` 与 `@app/design-tokens`。 ## i18n - 语言:`zh-CN`(`/zh-CN/...`)、`en`(`/en/...`)。**中文也带前缀**,不把中文留在裸 `/`。 -- **单套路由**:`src/pages/[locale]/index.astro`、`[locale]/dashboard.astro` + `src/views/*`;禁止再复制 `pages/zh-CN`、`pages/en` 目录。 +- **单套路由**:`src/pages/[locale]/...` + `src/views/*`;禁止再复制 `pages/zh-CN`、`pages/en` 目录。 - 文案:`@app/i18n` 的 `t(locale, key)`;React island 传 `locale` prop,不引入 i18next。 - 裸 `/`:`src/middleware.ts` 按 cookie `app-lang` → `Accept-Language` 跳 `/zh-CN` 或 `/en`;响应 `Vary: Accept-Language`;非法/大小写 `zh-cn` 规范化。 - 手动切换:header 语言链接写 cookie 后跳转(布局内联脚本)。 @@ -20,17 +20,28 @@ Astro SSR 前端(Node standalone):页面、布局、React islands、tRPC H ``` src/ pages/ - [locale]/index.astro | dashboard.astro | auth/* | settings.astro - api/trpc/[...trpc].ts | api/users.ts + [locale]/ + index.astro | dashboard.astro | library.astro | browse.astro + watch.astro | mounts.astro | danmaku.astro | settings.astro + auth/login.astro | auth/register.astro + auth/forgot-password.astro | auth/reset-password.astro + api/trpc/[...trpc].ts | api/stream.ts | api/users.ts sitemap.xml.ts | robots.txt.ts | llms.txt.ts | llms-full.txt.ts - views/ - home.astro | dashboard.astro | auth.astro | error.astro - middleware.ts # 裸 / 语言检测跳转 + locale 规范化 + 403.astro | 404.astro | 500.astro + views/ # SSR 壳 + seo/nav(每页一个) + home.astro | dashboard.astro | library.astro | browse.astro + watch.astro | mounts.astro | danmaku.astro | auth.astro | error.astro + middleware.ts # 裸 / 语言检测跳转 + locale 规范化 layouts/AppLayout.astro components/ + pages/ # 薄 SSR 主体(client:load) + HomeBody.tsx | DashboardBody.tsx | LibraryBody.tsx | BrowseBody.tsx + WatchBody.tsx | MountsBody.tsx | DanmakuSettingsBody.tsx + AuthBody.tsx | ErrorBody.tsx UserList.tsx | UserMenu.tsx | ThemeSwitch.tsx | AuthForm.tsx | errors/* lib/ - trpc.ts | seo.ts(getSiteSeo(locale))| theme.ts | utils.ts(cn re-export) + trpc.ts | seo.ts(getSiteSeo(locale))| nav.ts | theme.ts + danmaku-render.ts | errors.ts | utils.ts(cn re-export) styles/globals.css astro.config.ts ``` @@ -42,24 +53,28 @@ astro.config.ts - `@app/ui` → `packages/ui/src`(源码直出) - typecheck 时 tsconfig paths 指向各包 `src`——**改上游后先 build 再 dev/build web** -## 页面与 SEO +## 页面与 SEO(薄 SSR) +- **服务端**:`AppLayout.astro`(head、导航、脚注)、`seo` prop、middleware(locale / 鉴权跳转)、API / sitemap 等端点。 +- **客户端**:`views/*` 只保留壳,主体为 `components/pages/*Body.tsx`,统一 `client:load`。 - 布局统一走 `AppLayout.astro`,必传 `locale`,通过 `seo` prop 传 `PageSeoInput`。 - 站点 SEO:`lib/seo.ts` 的 `getSiteSeo(locale)`;sitemap 双语 URL + hreflang `alternates`。 -- 新公开页面:登记 `PUBLIC_PATHS` / views,并同步 `getSiteSeo` 的 `llms.pages` 与 sitemap。 +- 新公开页面:登记 `PUBLIC_PATHS` / views,并同步 `getSiteSeo` 的 `llms.pages` 与 sitemap;主体写成 `*Body.tsx`。 +- 导航项集中在 `lib/nav.ts`,改菜单只动这一处。 ## React islands -- 需要交互才加 `client:load` / `client:visible`。 +- 页面主体默认 `client:load`(薄 SSR);局部岛仍可用 `client:visible`。 - 文案:`import { t } from "@app/i18n"` + `locale` prop;不要绑 i18next Provider。 - tRPC:组件内 `QueryClientProvider` + trpc Provider(参考 `UserList.tsx`)。 - UI:`@app/ui`(通用基础组件)+ `cn()`(`~/lib/utils` re-export);颜色用 CSS 变量。 +- 播放页弹幕渲染逻辑在 `lib/danmaku-render.ts`,与 `WatchBody` 解耦。 ## 修改指南 - **加文案**:先改 `packages/i18n/src/messages/zh-CN.ts`,再补 `en.ts`。 -- **加业务页面**:在下游仓库扩展,勿把产品业务写回模板。 -- **调 API**:优先 tRPC;`api/users.ts` 仅演示。 +- **加业务页面**:沿用 `views/*` + `components/pages/*Body` 薄 SSR 模式扩展。 +- **调 API**:优先 tRPC(`trpc.media.*`);`api/users.ts` 仅演示;`api/stream.ts` 为受保护的媒体流。 - **主题**:`localStorage` key `app-theme`;语言 cookie `app-lang`。 - Astro 保留字:组件 props 避免 `slot`。 @@ -71,4 +86,5 @@ yarn workspace @app/web lint yarn workspace @app/web dev # :4321 yarn workspace @app/web build yarn dev:web +yarn test:e2e # Playwright(e2e/ 目录) ``` diff --git a/app/web/astro.config.ts b/app/web/astro.config.ts index b8da8a6..b1f6ac7 100644 --- a/app/web/astro.config.ts +++ b/app/web/astro.config.ts @@ -45,6 +45,8 @@ export default defineConfig({ }, ssr: { noExternal: ["@app/trpc", "@app/db", "@app/i18n", "@app/ui"], + // webdav 必须走 Node 原生 stream,不能被 SSR 打包掏空 + external: ["webdav"], }, }, server: { diff --git a/app/web/package.json b/app/web/package.json index a148fe4..de9683e 100644 --- a/app/web/package.json +++ b/app/web/package.json @@ -25,6 +25,7 @@ "@trpc/client": "^11.19.0", "@trpc/react-query": "^11.19.0", "artplayer": "^5.2.2", + "artplayer-plugin-danmuku": "^5.3.0", "astro": "^7.3.3", "gsap": "^3.15.0", "lenis": "^1.3.26", diff --git a/app/web/src/components/AppShell.tsx b/app/web/src/components/AppShell.tsx deleted file mode 100644 index 29e8f16..0000000 --- a/app/web/src/components/AppShell.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import type { ReactNode } from "react"; -import { cn } from "~/lib/utils"; - -const NAV = [ - { hrefKey: "nav.library", href: "library", id: "library" }, - { hrefKey: "nav.browse", href: "browse", id: "browse" }, - { hrefKey: "nav.mounts", href: "mounts", id: "mounts" }, - { hrefKey: "nav.danmaku", href: "danmaku", id: "danmaku" }, -] as const; - -export function AppShell(props: { - locale: string; - active: (typeof NAV)[number]["id"] | "watch"; - labels: Record; - children: ReactNode; -}) { - const base = `/${props.locale}`; - const product = props.labels["meta.product"] ?? props.labels["meta.brand"] ?? "弹弹 Web"; - - return ( -
- {/* 窄屏:顶栏品牌 + 横向导航(md 以下侧栏隐藏时的唯一入口) */} -
- - -
- - {/* 宽屏:侧栏 */} - - -
{props.children}
-
- ); -} diff --git a/app/web/src/components/AuthForm.tsx b/app/web/src/components/AuthForm.tsx index 1ab4456..7c96bee 100644 --- a/app/web/src/components/AuthForm.tsx +++ b/app/web/src/components/AuthForm.tsx @@ -1,10 +1,12 @@ /** * 认证表单岛:登录 / 注册 / 找回 / 重置 / 改密,按 `mode` 切换。 - * Provider 包在组件内部(与 UserList 同模式)。 + * 默认登录方式:用户名 + 密码。 + * Provider 包在组件内部。 */ import { useState } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { trpc, createTRPCReactClient } from "../lib/trpc"; +import { go } from "../lib/nav"; import type { Locale } from "@app/i18n"; import { t } from "@app/i18n"; import { @@ -48,6 +50,7 @@ export default function AuthForm(props: AuthFormProps) { } function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) { + const [username, setUsername] = useState(""); const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -59,7 +62,7 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) { const login = trpc.auth.login.useMutation({ onSuccess: () => { toast.success(t(locale, "auth.msg.loginOk")); - window.location.href = next?.startsWith("/") ? next : home(locale); + go(next?.startsWith("/") ? next : home(locale)); }, onError: (err) => toast.error(err.message || t(locale, "auth.err.generic")), }); @@ -67,7 +70,7 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) { const register = trpc.auth.register.useMutation({ onSuccess: () => { toast.success(t(locale, "auth.msg.registerOk")); - window.location.href = next?.startsWith("/") ? next : home(locale); + go(next?.startsWith("/") ? next : home(locale)); }, onError: (err) => toast.error(err.message || t(locale, "auth.err.generic")), }); @@ -86,7 +89,7 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) { const resetPassword = trpc.auth.resetPassword.useMutation({ onSuccess: () => { toast.success(t(locale, "auth.msg.resetOk")); - window.location.href = `${home(locale)}/auth/login`; + go(`${home(locale)}/auth/login`); }, onError: (err) => toast.error(err.message || t(locale, "auth.err.generic")), }); @@ -111,9 +114,14 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) { e.preventDefault(); if (busy) return; if (mode === "login") { - login.mutate({ email, password }); + login.mutate({ username, password }); } else if (mode === "register") { - register.mutate({ name, email, password }); + register.mutate({ + username, + password, + name: name.trim() || undefined, + email: email.trim() || undefined, + }); } else if (mode === "forgot") { requestReset.mutate({ email }); } else if (mode === "reset") { @@ -156,6 +164,22 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
+ {(mode === "login" || mode === "register") && ( +
+ + setUsername(e.target.value)} + /> +
+ )} + {mode === "register" && (
@@ -163,22 +187,25 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) { id="name" name="name" autoComplete="name" - required value={name} onChange={(e) => setName(e.target.value)} />
)} - {(mode === "login" || mode === "register" || mode === "forgot") && ( + {(mode === "register" || mode === "forgot") && (
- + setEmail(e.target.value)} /> @@ -213,6 +240,9 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) { value={password} onChange={(e) => setPassword(e.target.value)} /> +

+ {t(locale, "auth.passwordHint")} +

)} @@ -246,6 +276,9 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) { value={newPassword} onChange={(e) => setNewPassword(e.target.value)} /> +

+ {t(locale, "auth.passwordHint")} +

)} @@ -276,6 +309,9 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) { value={newPassword} onChange={(e) => setNewPassword(e.target.value)} /> +

+ {t(locale, "auth.passwordHint")} +

)} diff --git a/app/web/src/components/UserList.tsx b/app/web/src/components/UserList.tsx deleted file mode 100644 index 39a4766..0000000 --- a/app/web/src/components/UserList.tsx +++ /dev/null @@ -1,183 +0,0 @@ -/** - * 用户 CRUD 演示岛:tRPC + React Query。 - * Provider 包在组件内部,便于 Astro 以 client:visible 挂载而不污染全局。 - */ - -import type { RouterOutputs } from "@app/trpc"; -import { - Badge, - Button, - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, - Input, - Label, - Separator, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, - toast, -} from "@app/ui"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useState } from "react"; -import { createTRPCReactClient, trpc } from "../lib/trpc"; - -const queryClient = new QueryClient(); -const trpcClient = createTRPCReactClient(); - -type User = RouterOutputs["user"]["list"]["users"][number]; - -export default function UserList() { - return ( - - - - - - ); -} - -function UserListInner() { - const users = trpc.user.list.useQuery({ limit: 20, offset: 0 }); - const createUser = trpc.user.create.useMutation({ - onSuccess: () => { - users.refetch(); - toast.success("用户创建成功"); - }, - onError: (err) => { - toast.error(`创建失败: ${err.message}`); - }, - }); - - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!name || !email || !password) return; - await createUser.mutateAsync({ name, email, password }); - setName(""); - setEmail(""); - setPassword(""); - }; - - return ( -
- - - 用户列表 - 当前共 {users.data?.total ?? "—"} 位用户 - - - {users.isPending ? ( -
- 加载中… -
- ) : users.isError ? ( -
- 加载失败: {users.error.message} -
- ) : ( - - - - ID - 名称 - 邮箱 - 状态 - - - - {users.data?.users.length === 0 ? ( - - - 暂无数据 - - - ) : ( - users.data?.users.map((u: User) => ( - - - {u.id.slice(0, 8)}… - - {u.name} - {u.email} - - 活跃 - - - )) - )} - -
- )} -
-
- - - - 新建用户 - - - -
-
- - setName(e.target.value)} - required - /> -
-
- - setEmail(e.target.value)} - required - /> -
-
- - setPassword(e.target.value)} - required - /> -
-
- -
- - {createUser.isError && ( - - {createUser.error.message} - - )} -
- -
-
-
- ); -} diff --git a/app/web/src/components/UserMenu.tsx b/app/web/src/components/UserMenu.tsx index dad7cc5..567e37c 100644 --- a/app/web/src/components/UserMenu.tsx +++ b/app/web/src/components/UserMenu.tsx @@ -26,8 +26,9 @@ const trpcClient = createTRPCReactClient(); export interface UserMenuProps { locale?: Locale | undefined; + username?: string | undefined; name?: string | undefined; - email?: string | undefined; + email?: string | null | undefined; avatarUrl?: string | undefined; } @@ -99,7 +100,9 @@ function UserMenuInner({ locale, avatarUrl }: { locale: Locale; avatarUrl?: stri
{name} - {user.email} + + {user.email ?? user.username ?? ""} +
diff --git a/app/web/src/components/pages/AuthBody.tsx b/app/web/src/components/pages/AuthBody.tsx new file mode 100644 index 0000000..c00bff4 --- /dev/null +++ b/app/web/src/components/pages/AuthBody.tsx @@ -0,0 +1,24 @@ +/** + * 鉴权页主体:薄 SSR — 外层容器 + AuthForm 在浏览器渲染。 + */ +import type { Locale } from "@app/i18n"; +import { Toaster } from "@app/ui"; +import AuthForm, { type AuthFormMode } from "~/components/AuthForm"; + +export interface AuthBodyProps { + locale: Locale; + mode: AuthFormMode; + next?: string | undefined; + token?: string | undefined; +} + +export default function AuthBody({ locale, mode, next, token }: AuthBodyProps) { + return ( + <> +
+ +
+ + + ); +} diff --git a/app/web/src/components/pages/BrowseBody.tsx b/app/web/src/components/pages/BrowseBody.tsx index 11324f9..a1a27d6 100644 --- a/app/web/src/components/pages/BrowseBody.tsx +++ b/app/web/src/components/pages/BrowseBody.tsx @@ -1,9 +1,10 @@ import type { Locale } from "@app/i18n"; import { t } from "@app/i18n"; import { isVideoFilename } from "@app/types"; -import { Button } from "@app/ui"; +import { Button, Input, toast } from "@app/ui"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { go, queryParam, setQueryParams } from "~/lib/nav"; import { createTRPCReactClient, trpc } from "~/lib/trpc"; function pathOf(segments: string[]): string { @@ -17,13 +18,51 @@ function fileRelPath(dirPath: string, basename: string): string { function BrowseInner(props: { locale: Locale }) { const { locale } = props; const mounts = trpc.media.mountList.useQuery(); - const [mountId, setMountId] = useState(""); + const [mountId, setMountId] = useState(() => queryParam("mount") ?? ""); const [path, setPath] = useState("/"); const [crumbs, setCrumbs] = useState([]); + useEffect(() => { + setQueryParams({ mount: mountId || null }); + }, [mountId]); + + // 挂载列表返回后:URL 里的 mount 有效则保留;否则自动选中第一个已挂载目录 + useEffect(() => { + const list = mounts.data?.mounts ?? []; + if (list.length === 0) return; + if (mountId && list.some((m) => m.id === mountId)) return; + const first = list[0]; + if (first) setMountId(first.id); + }, [mounts.data, mountId]); + const dir = trpc.media.mountListDir.useQuery({ mountId, path }, { enabled: Boolean(mountId) }); const scan = trpc.media.libraryScan.useMutation(); const ensure = trpc.media.libraryEnsurePath.useMutation(); + const utils = trpc.useUtils(); + const batchScrape = trpc.media.scrapeRescrapePath.useMutation({ + onSuccess: (data) => { + toast.success(t(locale, "browse.batchDone", { message: data.message })); + void utils.media.libraryList.invalidate(); + void utils.media.libraryGroups.invalidate(); + }, + onError: () => toast.error(t(locale, "browse.batchError")), + }); + const bindPath = trpc.media.scrapeBindPath.useMutation({ + onSuccess: (data) => { + toast.success(t(locale, "browse.batchDone", { message: data.message })); + setBindOpen(false); + setBindQ(""); + void utils.media.libraryList.invalidate(); + void utils.media.libraryGroups.invalidate(); + }, + onError: () => toast.error(t(locale, "browse.batchError")), + }); + const [bindOpen, setBindOpen] = useState(false); + const [bindQ, setBindQ] = useState(""); + const bindSearch = trpc.media.scrapeSearch.useQuery( + { q: bindQ }, + { enabled: bindOpen && bindQ.length > 1 }, + ); function goRoot() { setCrumbs([]); @@ -52,7 +91,7 @@ function BrowseInner(props: { locale: Locale }) { if (!mountId) return; try { const item = await ensure.mutateAsync({ mountId, path: fileRelPath(path, basename) }); - window.location.href = `/${locale}/watch?id=${item.id}`; + go(`/${locale}/watch?id=${item.id}`); } catch { // error surfaced via ensure.error } @@ -97,12 +136,38 @@ function BrowseInner(props: { locale: Locale }) { {t(locale, "browse.addLibrary")} )} + {mountId && path !== "/" && ( + <> + + + + )} - {scan.data && ( + {(batchScrape.data || bindPath.data) && (

- {t(locale, "browse.scanDone", { count: scan.data.scanned })} + {t(locale, "browse.batchDone", { + message: batchScrape.data?.message ?? bindPath.data?.message ?? "", + })}

)} + {scan.data && ( +
+

+ {t(locale, "browse.scanDone", { count: scan.data.scanned })} +

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

{t(locale, "browse.scanError")}

)} @@ -128,7 +193,17 @@ function BrowseInner(props: { locale: Locale }) {

{t(locale, "browse.loading")}

)} {mountId && dir.isError && ( -

{t(locale, "browse.loadError")}

+
+

+ {t(locale, "browse.loadError")} + {dir.error instanceof Error && dir.error.message + ? `:${dir.error.message}` + : ""} +

+ +
)} {ensure.isError && (

{t(locale, "browse.playError")}

@@ -168,24 +243,82 @@ function BrowseInner(props: { locale: Locale }) { ); })} {mountId && dir.data?.entries.length === 0 && ( -
  • - {t(locale, "browse.empty")} +
  • +
    +

    {t(locale, "browse.empty")}

    +
  • )} - {!mountId && ( -
  • - {t(locale, "browse.empty")} - + {!mountId && mountList.length === 0 && ( +
  • +
    +

    {t(locale, "browse.mountsEmpty")}

    +
    + +
    +
    +
  • + )} + {!mountId && mountList.length > 0 && ( +
  • +
    +

    {t(locale, "browse.loading")}

    +
  • )} + + {bindOpen && mountId && ( +
    +
    +

    {t(locale, "browse.bindFolderTitle")}

    +

    + {t(locale, "browse.bindFolderHint")} +

    +

    {path}

    + setBindQ(e.target.value)} + /> +
      + {(bindSearch.data ?? []).map((hit) => ( +
    • +
      +
      {hit.nameCn || hit.name}
      + {hit.nameCn && hit.name && ( +
      + {hit.name} +
      + )} +
      + +
    • + ))} +
    + +
    +
    + )} ); } diff --git a/app/web/src/components/pages/DanmakuSettingsBody.tsx b/app/web/src/components/pages/DanmakuSettingsBody.tsx index 0a57339..5ac2e36 100644 --- a/app/web/src/components/pages/DanmakuSettingsBody.tsx +++ b/app/web/src/components/pages/DanmakuSettingsBody.tsx @@ -1,8 +1,9 @@ import type { Locale } from "@app/i18n"; import { t } from "@app/i18n"; -import { Button, toast } from "@app/ui"; +import { Button, Card, toast } from "@app/ui"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useEffect, useState } from "react"; +import { go } from "~/lib/nav"; import { createTRPCReactClient, trpc } from "~/lib/trpc"; const DANMAKU_SETTINGS_KEY = "media-danmaku-settings"; @@ -59,49 +60,81 @@ function Inner(props: { locale: Locale }) { }; return ( -
    +

    {t(locale, "danmaku.title")}

    - - - - - {save.isSuccess && ( -

    {t(locale, "danmaku.saved")}

    - )} -

    {t(locale, "danmaku.localImportHint")}

    + + +
    +
    + {t(locale, "danmaku.opacity")} + + {(opacity * 100).toFixed(0)}% + +
    + setOpacity(Number(e.target.value))} + className="w-full" + /> +
    +
    +
    + {t(locale, "danmaku.density")} + + {(density * 100).toFixed(0)}% + +
    + setDensity(Number(e.target.value))} + className="w-full" + /> +
    +
    + + {save.isSuccess && ( +

    {t(locale, "danmaku.saved")}

    + )} +
    +

    + {t(locale, "danmaku.localImportHint")} +

    +
    +
    + + +
    ); } diff --git a/app/web/src/components/pages/ErrorBody.tsx b/app/web/src/components/pages/ErrorBody.tsx new file mode 100644 index 0000000..73e81c0 --- /dev/null +++ b/app/web/src/components/pages/ErrorBody.tsx @@ -0,0 +1,22 @@ +/** + * 错误页主体:薄 SSR — ErrorView 在浏览器渲染。 + */ +import type { Locale } from "@app/i18n"; +import { ErrorView } from "~/components/errors/ErrorView"; +import { createAppError, type AppErrorStatus } from "~/lib/errors"; + +export interface ErrorBodyProps { + locale: Locale; + status: AppErrorStatus; +} + +export default function ErrorBody({ locale, status }: ErrorBodyProps) { + const error = createAppError(status); + const homeHref = locale === "zh-CN" ? "/zh-CN" : "/en"; + + return ( +
    + +
    + ); +} diff --git a/app/web/src/components/pages/HomeBody.tsx b/app/web/src/components/pages/HomeBody.tsx index 43e4d9a..fd186cc 100644 --- a/app/web/src/components/pages/HomeBody.tsx +++ b/app/web/src/components/pages/HomeBody.tsx @@ -2,15 +2,52 @@ * 首页主体:薄 SSR — Astro 壳负责 head/导航,本组件在浏览器渲染。 */ import { t, type Locale } from "@app/i18n"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState } from "react"; +import { go } from "~/lib/nav"; +import { createTRPCReactClient, trpc } from "~/lib/trpc"; export interface HomeBodyProps { locale: Locale; } -export default function HomeBody({ locale }: HomeBodyProps) { +function ContinueStrip(props: { locale: Locale }) { + const { locale } = props; + const list = trpc.media.playbackContinue.useQuery({ limit: 4 }, { retry: false }); + const items = list.data ?? []; + if (items.length === 0) return null; + return ( +
    +

    {t(locale, "library.continueTitle")}

    +
    + {items.map((item) => ( + + ))} +
    +
    + ); +} + +function HomeInner({ locale }: HomeBodyProps) { const tr = (key: Parameters[1]) => t(locale, key); const libraryHref = locale === "zh-CN" ? "/zh-CN/library" : "/en/library"; const loginHref = locale === "zh-CN" ? "/zh-CN/auth/login" : "/en/auth/login"; + const me = trpc.auth.me.useQuery(undefined, { retry: false }); + const loggedIn = me.data?.user != null; + + const steps = [ + { key: "nav.mounts", href: `/${locale}/mounts` }, + { key: "nav.browse", href: `/${locale}/browse` }, + { key: "nav.library", href: `/${locale}/library` }, + { key: "nav.danmaku", href: `/${locale}/danmaku` }, + ] as const; return (
    @@ -32,14 +69,18 @@ export default function HomeBody({ locale }: HomeBodyProps) { {tr("home.ctaDashboard")} - - {tr("home.ctaLogin")} - + {!loggedIn && ( + + {tr("home.ctaLogin")} + + )}
    + +
    -
    +

    {tr("home.stackTitle")}

    -
    +

    {tr("home.layerTitle")}

    {tr("home.layerBody")}

    +
    + {steps.map((s, i) => ( + + {String(i + 1).padStart(2, "0")} + {tr(s.key)} + + ))} +
    ); } + +const trpcClient = createTRPCReactClient(); + +export default function HomeBody({ locale }: HomeBodyProps) { + const [qc] = useState(() => new QueryClient()); + return ( + + + + + + ); +} diff --git a/app/web/src/components/pages/LibraryBody.tsx b/app/web/src/components/pages/LibraryBody.tsx index 49f25d1..fcd3031 100644 --- a/app/web/src/components/pages/LibraryBody.tsx +++ b/app/web/src/components/pages/LibraryBody.tsx @@ -1,85 +1,473 @@ import type { Locale } from "@app/i18n"; import { t } from "@app/i18n"; -import { Button, Input } from "@app/ui"; +import type { LibraryGroup } from "@app/types"; +import { Button, Input, toast } from "@app/ui"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { cn } from "~/lib/utils"; +import { go, queryParam, setQueryParams } from "~/lib/nav"; import { createTRPCReactClient, trpc } from "~/lib/trpc"; -function LibraryInner(props: { locale: Locale }) { - const { locale } = props; - const [filter, setFilter] = useState<"" | "unmatched" | "ok">(""); - const [q, setQ] = useState(""); - const [offset, setOffset] = useState(0); - const pageLimit = 24; - const list = trpc.media.libraryList.useQuery({ - limit: pageLimit, - offset, - scrapeStatus: filter || undefined, - q: q || undefined, - }); - const progressList = list.data?.rows ?? []; - const total = list.data?.total ?? 0; - const hasMore = offset + progressList.length < total; - const resetPaging = () => setOffset(0); - const [matchId, setMatchId] = useState(null); - const [searchQ, setSearchQ] = useState(""); - const search = trpc.media.scrapeSearch.useQuery( - { q: searchQ }, - { enabled: searchQ.length > 1 && matchId !== null }, +type ViewMode = "wall" | "list"; + +const PAGE_SIZE = 24; + +function useDebouncedValue(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(timer); + }, [value, delayMs]); + return debounced; +} + +function PosterBox(props: { posterUrl: string | null; locale: Locale; className?: string }) { + return ( +
    + {props.posterUrl ? ( + + ) : ( + t(props.locale, "library.noPoster") + )} +
    ); - const bind = trpc.media.scrapeBind.useMutation({ - onSuccess: () => { - setMatchId(null); - list.refetch(); - }, +} + +function MountTabs(props: { + mounts: { id: string; name: string }[]; + value: string; + onChange: (mountId: string) => void; +}) { + return ( +
    + {props.mounts.map((m) => ( + + ))} +
    + ); +} + +/** 作品详情:剧集按需从 librarySeries 拉取(限当前挂载) */ +function GroupDetail(props: { + bangumiId: number; + mountId: string; + locale: Locale; + onBack: () => void; + onPlay: (mediaItemId: string, bangumiId: number) => void; + onMatch?: ((mediaItemId: string) => void) | undefined; +}) { + const { locale } = props; + const seriesQuery = trpc.media.librarySeries.useQuery( + { bangumiId: props.bangumiId, mountId: props.mountId || undefined }, + { enabled: Boolean(props.mountId) }, + ); + const group = seriesQuery.data; + return ( +
    + + {seriesQuery.isPending && ( +

    {t(locale, "browse.loading")}

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

    {t(locale, "browse.loadError")}

    + )} + {group && ( +
    +
    + +
    +
    +

    + {group.title} +

    +

    + {t(locale, "library.episodeCount", { count: group.episodes.length })} +

    +
    +
    + {t(locale, "library.episodes")} +
    +
      + {group.episodes.map((ep) => ( +
    • + + {ep.epNumber != null ? `EP${ep.epNumber}` : "—"} + + + {ep.rawName} + + + {props.onMatch && ( + + )} +
    • + ))} +
    +
    +
    +
    + )} +
    + ); +} + +function ContinueRow(props: { locale: Locale }) { + const { locale } = props; + const list = trpc.media.playbackContinue.useQuery({ limit: 6 }); + const items = list.data ?? []; + if (list.isPending) return null; + if (items.length === 0) return null; + return ( +
    +
    +

    {t(locale, "library.continueTitle")}

    +
    +
    + {items.map((item) => { + const pct = + item.durationMs > 0 + ? Math.min(100, Math.round((item.positionMs / item.durationMs) * 100)) + : 0; + return ( + + ); + })} +
    +
    + ); +} + +/** + * 「加载更多」分页累积:按 offset 缓存已取页。 + * reqKey/refreshKey 变更时丢弃旧累积,避免 mutation 后旧页与新页混排。 + */ +type PagedAcc = { + key: string; + total: number; + pages: Record; +}; + +function usePagedAccumulator(params: { + reqKey: string; + refreshKey: number; + data: { total: number; items: T[] } | undefined; + offset: number; +}): { items: T[]; total: number; hasMore: boolean } { + const { reqKey, refreshKey, data, offset } = params; + const [acc, setAcc] = useState | null>(null); + const fullKey = `${reqKey}|r${refreshKey}`; + const [prevFullKey, setPrevFullKey] = useState(fullKey); + // 渲染期重置:条件或 refreshKey 变化后丢弃旧页 + if (prevFullKey !== fullKey) { + setPrevFullKey(fullKey); + setAcc(null); + } + + useEffect(() => { + if (!data) return; + setAcc((prev) => { + if (!prev || prev.key !== fullKey || offset === 0) { + return { key: fullKey, total: data.total, pages: { [offset]: data.items } }; + } + return { + key: prev.key, + total: data.total, + pages: { ...prev.pages, [offset]: data.items }, + }; + }); + }, [data, fullKey, offset]); + + const items: T[] = []; + let total = 0; + if (acc && acc.key === fullKey) { + total = acc.total; + for (const pageOffset of Object.keys(acc.pages) + .map(Number) + .sort((a, b) => a - b)) { + const page = acc.pages[pageOffset]; + if (page) items.push(...page); + } + } + return { items, total, hasMore: items.length < total }; +} + +function WallView(props: { + locale: Locale; + mountId: string; + q: string; + refreshKey: number; + selectedBangumiId: number | null; + onSelect: (bangumiId: number | null) => void; + onPlay: (mediaItemId: string, bangumiId: number) => void; + onMatch?: ((mediaItemId: string) => void) | undefined; + onClearSearch?: (() => void) | undefined; + hideEmpty?: boolean; + mountsEmpty?: boolean; +}) { + const { locale, mountId, q } = props; + const [offset, setOffset] = useState(0); + const reqKey = `${mountId}|${q}`; + const [prevReqKey, setPrevReqKey] = useState(reqKey); + if (prevReqKey !== reqKey) { + setPrevReqKey(reqKey); + setOffset(0); + } + const [prevRefresh, setPrevRefresh] = useState(props.refreshKey); + if (prevRefresh !== props.refreshKey) { + setPrevRefresh(props.refreshKey); + setOffset(0); + } + const groupsQuery = trpc.media.libraryGroups.useQuery( + { mountId, q: q || undefined, limit: PAGE_SIZE, offset }, + { enabled: Boolean(mountId) }, + ); + const paged = usePagedAccumulator({ + reqKey, + refreshKey: props.refreshKey, + data: groupsQuery.data + ? { total: groupsQuery.data.total, items: groupsQuery.data.groups } + : undefined, + offset, }); + const groups = paged.items; + const hasMore = paged.hasMore; + + if (props.selectedBangumiId != null) { + return ( + props.onSelect(null)} + onPlay={props.onPlay} + onMatch={props.onMatch} + /> + ); + } + + const showEmpty = + !props.hideEmpty && (props.mountsEmpty || (groups.length === 0 && !groupsQuery.isPending)); return (
    -
    -

    {t(locale, "library.title")}

    -
    - { - setQ(e.target.value); - resetPaging(); - }} - /> - + +
    +
    + {group.title} +
    +
    + {t(locale, "library.episodeCount", { count: group.episodeCount })} +
    +
    + + ))} + {showEmpty && ( +
    +

    {t(locale, "library.title")}

    +

    + {!props.mountsEmpty && q + ? t(locale, "library.noResults", { q }) + : t(locale, "library.groupsEmpty")} +

    +
    + {!props.mountsEmpty && q && props.onClearSearch ? ( + + ) : ( + <> + + + + )} +
    +
    + )} +
    + {hasMore && ( +
    +
    + )} +
    + ); +} + +function ListView(props: { + locale: Locale; + mountId: string; + q: string; + filter: "" | "unmatched" | "ok"; + refreshKey: number; + onFilterChange: (f: "" | "unmatched" | "ok") => void; + onMatch: (mediaItemId: string) => void; + onPlay: (mediaItemId: string, bangumiId?: number | null) => void; + onRescrape?: ((mediaItemId: string) => void) | undefined; + onClearSearch?: (() => void) | undefined; + hideEmpty?: boolean; + mountsEmpty?: boolean; +}) { + const { locale, mountId, q, filter } = props; + const [offset, setOffset] = useState(0); + const pageLimit = PAGE_SIZE; + const reqKey = `${mountId}|${q}|${filter}`; + const [prevReqKey, setPrevReqKey] = useState(reqKey); + if (prevReqKey !== reqKey) { + setPrevReqKey(reqKey); + setOffset(0); + } + const [prevRefresh, setPrevRefresh] = useState(props.refreshKey); + if (prevRefresh !== props.refreshKey) { + setPrevRefresh(props.refreshKey); + setOffset(0); + } + const list = trpc.media.libraryList.useQuery({ + limit: pageLimit, + offset, + mountId: mountId || undefined, + scrapeStatus: filter || undefined, + q: q || undefined, + }); + // 与海报墙同一套累积逻辑:加载更多追加而不是顶掉上一页 + type MediaListItem = { + id: string; + title: string; + posterUrl: string | null; + bangumiId: number | null; + scrapeStatus: string; + }; + const paged = usePagedAccumulator({ + reqKey, + refreshKey: props.refreshKey, + data: list.data + ? { + total: list.data.total, + items: list.data.rows.map((r) => ({ + id: r.id, + title: r.title, + posterUrl: r.posterUrl, + bangumiId: r.bangumiId, + scrapeStatus: r.scrapeStatus, + })), + } + : undefined, + offset, + }); + const rows = paged.items; + const hasMore = paged.hasMore; + + return ( +
    +
    +
    - {progressList.map((item) => ( + {rows.map((item) => (
    -
    - {item.posterUrl ? ( - - ) : ( - t(locale, "library.noPoster") - )} -
    +
    {item.title} @@ -88,49 +476,73 @@ function LibraryInner(props: { locale: Locale }) { - {item.scrapeStatus !== "ok" && ( - - )} + +
    ))} - {progressList.length === 0 && !list.isPending && ( -
    -

    - {t(locale, "library.empty")} -

    -
    - - + {!props.hideEmpty && + (props.mountsEmpty || (rows.length === 0 && !list.isPending)) && ( +
    +

    {t(locale, "library.title")}

    +

    + {!props.mountsEmpty && q + ? t(locale, "library.noResults", { q }) + : !props.mountsEmpty && filter + ? t(locale, "library.filterEmpty") + : t(locale, "library.empty")} +

    +
    + {!props.mountsEmpty && q && props.onClearSearch ? ( + + ) : !props.mountsEmpty && filter ? ( + + ) : ( + <> + + + + )} +
    -
    - )} + )}
    {hasMore && (
    @@ -143,40 +555,273 @@ function LibraryInner(props: { locale: Locale }) {
    )} +
    + ); +} + +function LibraryInner(props: { locale: Locale }) { + const { locale } = props; + const [view, setView] = useState(() => { + try { + return localStorage.getItem("media-library-view") === "list" ? "list" : "wall"; + } catch { + return "wall"; + } + }); + const changeView = (v: ViewMode) => { + setView(v); + try { + localStorage.setItem("media-library-view", v); + } catch { + /* noop */ + } + }; + const [filter, setFilter] = useState<"" | "unmatched" | "ok">(""); + const [q, setQ] = useState(""); + const debouncedQ = useDebouncedValue(q, 300); + const [matchId, setMatchId] = useState(null); + const [searchQ, setSearchQ] = useState(""); + const [epInput, setEpInput] = useState(""); + const [mountId, setMountId] = useState(() => queryParam("mount") ?? ""); + const [selectedBangumiId, setSelectedBangumiId] = useState(() => { + const raw = queryParam("g"); + const parsed = raw ? Number(raw) : Number.NaN; + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + }); + const search = trpc.media.scrapeSearch.useQuery( + { q: searchQ }, + { enabled: searchQ.length > 1 && matchId !== null }, + ); + const utils = trpc.useUtils(); + const [refreshKey, setRefreshKey] = useState(0); + const bumpRefresh = () => setRefreshKey((k) => k + 1); + const bind = trpc.media.scrapeBind.useMutation({ + onSuccess: () => { + setMatchId(null); + toast.success(t(locale, "library.matched")); + bumpRefresh(); + void utils.media.libraryList.invalidate(); + void utils.media.libraryGroups.invalidate(); + void utils.media.librarySeries.invalidate(); + void utils.media.playbackContinue.invalidate(); + }, + }); + const rescrape = trpc.media.scrapeRescrape.useMutation({ + onSuccess: () => { + toast.success(t(locale, "library.reScrape")); + bumpRefresh(); + void utils.media.libraryList.invalidate(); + void utils.media.libraryGroups.invalidate(); + void utils.media.librarySeries.invalidate(); + }, + }); + + const bindWithEp = (bangumiId: number) => { + if (!matchId) return; + const epNum = epInput.trim() === "" ? undefined : Number(epInput); + bind.mutate({ + mediaItemId: matchId, + bangumiId, + ...(epNum != null && Number.isFinite(epNum) ? { epNumber: epNum } : {}), + }); + }; + + const mounts = trpc.media.mountList.useQuery(); + const probe = trpc.media.libraryList.useQuery({ limit: 1, offset: 0 }); + const autoScan = trpc.media.libraryScan.useMutation(); + const autoScanStarted = useRef(false); + const [autoScanning, setAutoScanning] = useState(false); + const mountList = mounts.data?.mounts ?? []; + const mountsEmpty = !mounts.isPending && mountList.length === 0; + + useEffect(() => { + setQueryParams({ mount: mountId || null }); + }, [mountId]); + + // 挂载列表返回后:URL 里的 mount 有效则保留;否则自动选中第一个 + useEffect(() => { + const list = mounts.data?.mounts ?? []; + if (list.length === 0) return; + if (mountId && list.some((m) => m.id === mountId)) return; + const first = list[0]; + if (first) setMountId(first.id); + }, [mounts.data, mountId]); + + // 空库且已有挂载时,自动把已挂载目录扫进媒体库(仅触发一次;顺序扫描避免并发打满 WebDAV) + useEffect(() => { + if (autoScanStarted.current) return; + if (mounts.isPending || probe.isPending) return; + const list = mounts.data?.mounts ?? []; + if (list.length === 0) return; + if ((probe.data?.total ?? 0) > 0) return; + autoScanStarted.current = true; + setAutoScanning(true); + void (async () => { + for (const m of list) { + try { + await autoScan.mutateAsync({ mountId: m.id }); + } catch { + // 单个挂载失败不阻断其余挂载 + } + } + setAutoScanning(false); + setRefreshKey((k) => k + 1); + void utils.media.libraryList.invalidate(); + void utils.media.libraryGroups.invalidate(); + })(); + }, [mounts.data, mounts.isPending, probe.data, probe.isPending, autoScan, utils]); + + useEffect(() => { + setQueryParams({ g: selectedBangumiId != null ? String(selectedBangumiId) : null }); + }, [selectedBangumiId]); + + const play = (id: string, bangumiId?: number | null) => { + const g = bangumiId ?? selectedBangumiId; + const back = g != null ? `&from=series&g=${g}` : ""; + go(`/${locale}/watch?id=${id}${back}`); + }; + + return ( +
    + + {autoScanning && ( +

    {t(locale, "library.autoScanning")}

    + )} +
    +

    {t(locale, "library.title")}

    +
    +
    + + +
    + setQ(e.target.value)} + /> +
    +
    + + {mountList.length > 0 && ( + { + setMountId(id); + setSelectedBangumiId(null); + }} + /> + )} + + {view === "wall" ? ( + setQ("")} + hideEmpty={autoScanning} + mountsEmpty={mountsEmpty} + /> + ) : ( + rescrape.mutate({ mediaItemId: id })} + onClearSearch={() => setQ("")} + hideEmpty={autoScanning} + mountsEmpty={mountsEmpty} + /> + )} {matchId && (
    -

    {t(locale, "library.matchTitle")}

    +

    {t(locale, "library.manualTitle")}

    setSearchQ(e.target.value)} /> +
    + + {t(locale, "library.epLabel")} + + setEpInput(e.target.value)} + /> +
    +

    + {t(locale, "library.candidates")} +

      {(search.data ?? []).map((hit) => (
    • - {hit.nameCn || hit.name} -
    • ))}
    - +
    + +
    )} diff --git a/app/web/src/components/pages/MountsBody.tsx b/app/web/src/components/pages/MountsBody.tsx index d0a03e5..565380d 100644 --- a/app/web/src/components/pages/MountsBody.tsx +++ b/app/web/src/components/pages/MountsBody.tsx @@ -1,135 +1,445 @@ import type { Locale } from "@app/i18n"; import { t } from "@app/i18n"; -import { Button, Input } from "@app/ui"; +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Input, + Label, + toast, +} from "@app/ui"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { go } from "~/lib/nav"; import { createTRPCReactClient, trpc } from "~/lib/trpc"; +import { formatTrpcError } from "~/lib/trpc-error"; +import { cn } from "~/lib/utils"; + +type FormMode = "create" | "edit"; + +type FormState = { + name: string; + baseUrl: string; + username: string; + password: string; + rootPath: string; +}; + +type FieldErrors = Partial>; + +function validateForm(form: FormState): FieldErrors { + const errs: FieldErrors = {}; + if (!form.name.trim()) errs.name = "name"; + const url = form.baseUrl.trim(); + if (!url) { + errs.baseUrl = "required"; + } else { + try { + const u = new URL(url); + if (u.protocol !== "http:" && u.protocol !== "https:") errs.baseUrl = "protocol"; + } catch { + errs.baseUrl = "invalid"; + } + } + return errs; +} + +const EMPTY_FORM: FormState = { + name: "", + baseUrl: "", + username: "", + password: "", + rootPath: "/", +}; function MountsInner(props: { locale: Locale }) { const { locale } = props; const utils = trpc.useUtils(); const list = trpc.media.mountList.useQuery(); + const formRef = useRef(null); + const create = trpc.media.mountCreate.useMutation({ - onSuccess: () => utils.media.mountList.invalidate(), + onSuccess: (data) => { + toast.success(t(locale, "mounts.saved")); + void utils.media.mountList.invalidate(); + setForm(EMPTY_FORM); + setFieldErrors({}); + setCreatedMountId(data.mount.id); + }, + onError: (err) => toast.error(formatTrpcError(err, t(locale, "mounts.saveError"))), + }); + const update = trpc.media.mountUpdate.useMutation({ + onSuccess: () => { + toast.success(t(locale, "mounts.updated")); + void utils.media.mountList.invalidate(); + setMode("create"); + setEditId(null); + setForm(EMPTY_FORM); + setFieldErrors({}); + }, + onError: (err) => toast.error(formatTrpcError(err, t(locale, "mounts.saveError"))), }); const test = trpc.media.mountTest.useMutation(); const del = trpc.media.mountDelete.useMutation({ - onSuccess: () => utils.media.mountList.invalidate(), - }); - const [form, setForm] = useState({ - name: "", - baseUrl: "", - username: "", - password: "", - rootPath: "/", + onSuccess: (data) => { + toast.success( + data.removedItems > 0 + ? t(locale, "mounts.deletedN", { n: data.removedItems }) + : t(locale, "mounts.deleted"), + ); + void utils.media.mountList.invalidate(); + if (editId) { + setMode("create"); + setEditId(null); + setForm(EMPTY_FORM); + } + }, + onError: (err) => toast.error(formatTrpcError(err, t(locale, "mounts.deleteError"))), }); + const [mode, setMode] = useState("create"); + const [editId, setEditId] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + const [fieldErrors, setFieldErrors] = useState({}); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + const [createdMountId, setCreatedMountId] = useState(null); + + const saving = create.isPending || update.isPending; + const mountList = list.data?.mounts ?? []; + + function startEdit(mount: { + id: string; + name: string; + baseUrl: string; + username: string | null; + rootPath: string; + }) { + setMode("edit"); + setEditId(mount.id); + setCreatedMountId(null); + test.reset(); + setForm({ + name: mount.name, + baseUrl: mount.baseUrl, + username: mount.username ?? "", + password: "", + rootPath: mount.rootPath || "/", + }); + formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + formRef.current?.querySelector("input")?.focus(); + } + + function cancelEdit() { + setMode("create"); + setEditId(null); + test.reset(); + setForm(EMPTY_FORM); + } + + useEffect(() => { + if (mode === "edit") return; + if (mountList.length > 0) setCreatedMountId(null); + }, [mountList.length, mode]); + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + const errs = validateForm(form); + setFieldErrors(errs); + if (errs.name || errs.baseUrl) { + const firstKey = errs.name ? "mount-name" : "mount-url"; + formRef.current?.querySelector(`#${firstKey}`)?.focus(); + return; + } + const base = { + name: form.name.trim(), + baseUrl: form.baseUrl.trim(), + username: form.username.trim() || undefined, + rootPath: form.rootPath.trim() || "/", + }; + if (mode === "edit" && editId) { + // 密码留空 = 不修改原凭据 + update.mutate({ + id: editId, + ...base, + ...(form.password ? { password: form.password } : {}), + }); + } else { + create.mutate({ + ...base, + type: "webdav", + password: form.password || undefined, + enabled: true, + }); + } + } + + function onTest() { + test.mutate({ + baseUrl: form.baseUrl.trim(), + username: form.username.trim() || undefined, + password: form.password || undefined, + rootPath: form.rootPath.trim() || "/", + }); + } + return (
    -

    {t(locale, "mounts.title")}

    -

    - {t(locale, "mounts.openlistHint")} {t(locale, "mounts.credentialHint")} -

    -
    { - e.preventDefault(); - create.mutate({ - name: form.name, - type: "webdav", - baseUrl: form.baseUrl, - username: form.username || undefined, - password: form.password || undefined, - rootPath: form.rootPath || "/", - enabled: true, - }); - }} - > - setForm({ ...form, name: e.target.value })} - required - /> - setForm({ ...form, baseUrl: e.target.value })} - required - /> - setForm({ ...form, username: e.target.value })} - /> - setForm({ ...form, password: e.target.value })} - /> - setForm({ ...form, rootPath: e.target.value })} - /> -
    - + ) : ( -
    - {test.data && ( -

    - {test.data.message} -

    )} +
    + + + +
    +

    + {mode === "edit" + ? t(locale, "mounts.editHeading") + : t(locale, "mounts.add")} +

    +

    + {t(locale, "mounts.credentialHint")} +

    +
    +
    +
    + + { + setForm({ ...form, name: e.target.value }); + if (fieldErrors.name) + setFieldErrors({ ...fieldErrors, name: undefined }); + }} + /> + {fieldErrors.name && ( +

    + {t(locale, "mounts.errNameRequired")} +

    + )} +
    +
    + + { + setForm({ ...form, baseUrl: e.target.value }); + if (fieldErrors.baseUrl) { + setFieldErrors({ ...fieldErrors, baseUrl: undefined }); + } + }} + /> + {fieldErrors.baseUrl && ( +

    + {t(locale, "mounts.errUrlInvalid")} +

    + )} +
    +
    + + setForm({ ...form, username: e.target.value })} + /> +
    +
    + + setForm({ ...form, password: e.target.value })} + /> +
    +
    + + setForm({ ...form, rootPath: e.target.value })} + /> +
    +
    +
    + + + {mode === "edit" && ( + + )} + {test.data && ( +

    + {test.data.message} +

    + )} +
    +

    + {t(locale, "mounts.openlistHint")} +

    +
    -
      - {(list.data?.mounts ?? []).map((m) => ( -
    • -
      -
      {m.name}
      -
      - {m.baseUrl} + {createdMountId && ( +
      +

      {t(locale, "mounts.afterCreateHint")}

      + +
      + )} + +
      +

      {t(locale, "mounts.listTitle")}

      +
      + {mountList.map((m) => ( + + +
      + {m.name} +
      + {editId === m.id && ( + + {t(locale, "mounts.editing")} + + )} + {!m.enabled && ( + + {t(locale, "mounts.disabled")} + + )} +
      +
      + + {m.baseUrl} + {m.rootPath && m.rootPath !== "/" ? ` · ${m.rootPath}` : ""} + +
      + + + + + {confirmDeleteId === m.id && ( + + {t(locale, "mounts.deleteHint")} + + )} + +
      + ))} + {list.isSuccess && mountList.length === 0 && ( +
      +

      {t(locale, "mounts.empty")}

      +

      {t(locale, "mounts.openlistHint")}

      +
      +
      -
      - -
      -
    • - ))} - {list.data?.mounts.length === 0 && ( -
    • - {t(locale, "mounts.empty")} -
    • - )} -
    + )} +
    +
    ); } diff --git a/app/web/src/components/pages/WatchBody.tsx b/app/web/src/components/pages/WatchBody.tsx index 9918270..7c85e3a 100644 --- a/app/web/src/components/pages/WatchBody.tsx +++ b/app/web/src/components/pages/WatchBody.tsx @@ -1,16 +1,32 @@ import type { Locale } from "@app/i18n"; import { t } from "@app/i18n"; -import { Button } from "@app/ui"; +import { Button, toast } from "@app/ui"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import Artplayer from "artplayer"; +import artplayerPluginDanmuku from "artplayer-plugin-danmuku"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { parseDanmakuXml, startDanmakuLoop } from "~/lib/danmaku-render"; +import { parseDanmakuXml, thinDanmaku } from "~/lib/danmaku-render"; +import { go, queryParam } from "~/lib/nav"; import { createTRPCReactClient, trpc } from "~/lib/trpc"; +import { cn } from "~/lib/utils"; const DANMAKU_SETTINGS_KEY = "media-danmaku-settings"; type DanmakuPrefs = { enabled: boolean; opacity: number; density: number }; +type DanmukuPlugin = { + load: (danmuku: unknown) => Promise | unknown; + show: () => unknown; + hide: () => unknown; +}; + +function danmukuPlugin(art: Artplayer): DanmukuPlugin | null { + const plugins = art.plugins as Record; + const dm = plugins["artplayerPluginDanmuku"]; + if (!dm || typeof dm !== "object") return null; + return dm as DanmukuPlugin; +} + type ProgressInput = { mediaItemId: string; positionMs: number; @@ -38,6 +54,35 @@ function readDanmakuPrefs(): DanmakuPrefs { } } +function writeDanmakuPrefs(prefs: DanmakuPrefs) { + try { + localStorage.setItem(DANMAKU_SETTINGS_KEY, JSON.stringify(prefs)); + } catch { + /* noop */ + } +} + +const DANMAKU_XML_PREFIX = "media-danmaku-xml:"; +/** 单条弹幕 XML 本地持久化上限(localStorage 常规配额约 5MB,留余量) */ +const DANMAKU_XML_MAX_BYTES = 2 * 1024 * 1024; + +function readLocalXml(mediaItemId: string): string { + try { + return localStorage.getItem(DANMAKU_XML_PREFIX + mediaItemId) ?? ""; + } catch { + return ""; + } +} + +function writeLocalXml(mediaItemId: string, xml: string) { + try { + if (new Blob([xml]).size > DANMAKU_XML_MAX_BYTES) return; + localStorage.setItem(DANMAKU_XML_PREFIX + mediaItemId, xml); + } catch { + /* 配额不足时仅内存生效 */ + } +} + /** 页面卸载时 fetch 可能被取消,用 keepalive 走与 httpBatchLink 相同的批格式兜底 */ function sendProgressBeacon(input: ProgressInput) { const url = "/api/trpc/media.playbackReport?batch=1"; @@ -65,14 +110,22 @@ function sendProgressBeacon(input: ProgressInput) { function WatchInner(props: { locale: Locale; mediaItemId: string }) { const { locale, mediaItemId } = props; - const wrapperRef = useRef(null); const playerRef = useRef(null); const artRef = useRef(null); const progressStartRef = useRef(0); + const prefsRef = useRef(readDanmakuPrefs()); const [localXml, setLocalXml] = useState(""); - const [danmakuOn, setDanmakuOn] = useState(true); - const [danmakuOpacity, setDanmakuOpacity] = useState(0.8); - const [danmakuDensity, setDanmakuDensity] = useState(1); + const [danmakuOn, setDanmakuOn] = useState(prefsRef.current.enabled); + const [danmakuDensity, setDanmakuDensity] = useState(prefsRef.current.density); + const [ended, setEnded] = useState(false); + const [playError, setPlayError] = useState(false); + + // 进入/切换条目时恢复本地导入的弹幕 + useEffect(() => { + setLocalXml(readLocalXml(mediaItemId)); + setPlayError(false); + setEnded(false); + }, [mediaItemId]); const detail = trpc.media.libraryGet.useQuery({ id: mediaItemId }); const progress = trpc.media.playbackGet.useQuery({ mediaItemId }); @@ -84,10 +137,37 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { reportRef.current = report; }); + const bangumiId = detail.data?.bangumiId ?? null; + const series = trpc.media.librarySeries.useQuery( + { bangumiId: bangumiId ?? 0 }, + { enabled: bangumiId != null && bangumiId > 0 }, + ); + const episodes = useMemo(() => { + const list = series.data?.episodes ?? []; + return [...list].sort((a, b) => (a.epNumber ?? 0) - (b.epNumber ?? 0)); + }, [series.data]); + const epIndex = episodes.findIndex((ep) => ep.id === mediaItemId); + const prevEp = epIndex > 0 ? episodes[epIndex - 1] : null; + const nextEp = epIndex >= 0 && epIndex < episodes.length - 1 ? episodes[epIndex + 1] : null; + + const openEpisode = useCallback( + (id: string) => { + const g = bangumiId != null ? `&from=series&g=${bangumiId}` : ""; + go(`/${locale}/watch?id=${id}${g}`); + }, + [locale, bangumiId], + ); + + const goSeries = useCallback(() => { + const g = queryParam("g") ?? (bangumiId != null ? String(bangumiId) : null); + if (g != null) go(`/${locale}/library?g=${g}`); + else go(`/${locale}/library`); + }, [locale, bangumiId]); + useEffect(() => { const prefs = readDanmakuPrefs(); + prefsRef.current = prefs; setDanmakuOn(prefs.enabled); - setDanmakuOpacity(prefs.opacity); setDanmakuDensity(prefs.density); }, []); @@ -101,6 +181,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { useEffect(() => { const mountEl = playerRef.current; if (!mountEl || !detailId) return; + const prefs = prefsRef.current; const art = new Artplayer({ container: mountEl, url: `/api/stream?id=${encodeURIComponent(mediaItemId)}`, @@ -111,10 +192,56 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { playbackRate: true, aspectRatio: true, fullscreen: true, - theme: "#3b82f6", + theme: "#FB7299", + plugins: [ + artplayerPluginDanmuku({ + danmuku: [], + opacity: prefs.opacity, + fontSize: 25, + speed: 5, + margin: [10, "25%"], + color: "#FFFFFF", + mode: 0, + antiOverlap: true, + visible: prefs.enabled, + emitter: true, + }), + ], }); artRef.current = art; + // 常用快捷键(ArtPlayer 默认不含 f/m,补注册) + art.hotkey.add("KeyF", () => { + art.fullscreen = !art.fullscreen; + }); + art.hotkey.add("KeyM", () => { + art.muted = !art.muted; + }); + + // 播放失败 → 业务级错误卡(含重试);加载成功后清除 + const onVideoError = () => { + setPlayError(true); + }; + const onVideoReady = () => { + setPlayError(false); + }; + art.on("video:error", onVideoError); + art.on("video:loadeddata", onVideoReady); + art.on("playing", onVideoReady); + + // 播放器内弹幕设置变更 → 回写本地默认偏好 + art.on("artplayerPluginDanmuku:config", (...args: unknown[]) => { + const option = (args[0] ?? {}) as { opacity?: number; visible?: boolean }; + const prev = prefsRef.current; + const next: DanmakuPrefs = { + enabled: option.visible ?? prev.enabled, + opacity: typeof option.opacity === "number" ? option.opacity : prev.opacity, + density: prev.density, + }; + prefsRef.current = next; + writeDanmakuPrefs(next); + }); + const seekToStart = () => { const target = progressStartRef.current / 1000; if (target > 0 && art.duration > target) { @@ -146,11 +273,13 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { }; let timer: ReturnType | null = null; - art.on("play", () => { + // video:play / video:pause 是原生事件转发:覆盖 UI 按钮与系统媒体键/程序化暂停 + art.on("video:play", () => { + setEnded(false); if (timer) clearInterval(timer); timer = setInterval(reportNow, 5000); }); - art.on("pause", () => { + art.on("video:pause", () => { if (timer) { clearInterval(timer); timer = null; @@ -163,6 +292,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { timer = null; } reportNow(); + setEnded(true); }); const onVisibility = () => { @@ -194,24 +324,28 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { const xmlSource = danmakuOn ? localXml || openDanmaku.data?.xml || "" : ""; const items = useMemo(() => { const all = xmlSource ? parseDanmakuXml(xmlSource) : []; - // density < 1 时按比例抽稀,避免同屏弹幕过密 - if (danmakuDensity >= 1) return all; - const keepEvery = Math.max(1, Math.round(1 / danmakuDensity)); - return all.filter((_, i) => i % keepEvery === 0); + return thinDanmaku(all, danmakuDensity); }, [xmlSource, danmakuDensity]); const danmakuCount = items.length; + // 弹幕数据变化时交给官方插件渲染 useEffect(() => { - const wrapper = wrapperRef.current; - if (!wrapper || items.length === 0) return; - const stop = startDanmakuLoop( - wrapper, - () => items, - () => artRef.current?.currentTime ?? 0, - { opacity: danmakuOpacity }, - ); - return stop; - }, [items, danmakuOpacity]); + const art = artRef.current; + if (!art) return; + const dm = danmukuPlugin(art); + if (!dm) return; + void dm.load(items); + }, [items]); + + // 外部开关同步到插件显隐 + useEffect(() => { + const art = artRef.current; + if (!art) return; + const dm = danmukuPlugin(art); + if (!dm) return; + if (danmakuOn) dm.show(); + else dm.hide(); + }, [danmakuOn]); const onImportXml = useCallback( (file: File) => { @@ -219,15 +353,37 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { reader.onload = () => { const text = String(reader.result ?? ""); setLocalXml(text); - importMeta.mutate({ mediaItemId, byteSize: file.size }); + writeLocalXml(mediaItemId, text); + importMeta.mutate( + { mediaItemId, byteSize: file.size }, + { + onSuccess: () => toast.success(t(locale, "watch.importOk")), + onError: () => toast.error(t(locale, "watch.importFail")), + }, + ); }; reader.readAsText(file); }, - [importMeta, mediaItemId], + [importMeta, mediaItemId, locale], ); + const replay = useCallback(() => { + const art = artRef.current; + if (!art) return; + setEnded(false); + art.currentTime = 0; + void art.play(); + }, []); + + const retryStream = useCallback(() => { + const art = artRef.current; + if (!art) return; + setPlayError(false); + art.switchUrl(`/api/stream?id=${encodeURIComponent(mediaItemId)}`); + }, [mediaItemId]); + const goLibrary = useCallback(() => { - window.location.href = `/${locale}/library`; + go(`/${locale}/library`); }, [locale]); if (detail.isError) { @@ -257,18 +413,22 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { ); } return ( -
    -

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

    -

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

    - +
    +

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

    +

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

    +
    + +
    ); } const sourceLabel = !danmakuOn - ? t(locale, "watch.danmakuOff") + ? localXml + ? t(locale, "watch.danmakuDisabledHint") + : t(locale, "watch.danmakuOff") : localXml ? t(locale, "watch.danmakuCount", { count: danmakuCount }) : openDanmaku.data?.source === "none" @@ -283,14 +443,12 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {

    {detail.data.title}

    -
    - + )} +
    -
    +
    + {playError && ( +
    +

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

    +

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

    +
    + + +
    +
    + )}
    -
    +
    {sourceLabel} {t(locale, "watch.progressHint")}
    + + {episodes.length > 0 && ( +
    +
    +

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

    +
    +
      + {episodes.map((ep) => { + const current = ep.id === mediaItemId; + return ( +
    • + +
    • + ); + })} +
    +
    + )} + + {ended && nextEp && ( +
    +

    + {t(locale, "watch.episodeN", { + n: nextEp.epNumber ?? episodes.indexOf(nextEp) + 1, + })} +

    + +
    + )} + + {ended && !nextEp && ( +
    +

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

    +
    + + +
    +
    + )}
    ); } diff --git a/app/web/src/layouts/AppLayout.astro b/app/web/src/layouts/AppLayout.astro index 242a3dc..b48dea0 100644 --- a/app/web/src/layouts/AppLayout.astro +++ b/app/web/src/layouts/AppLayout.astro @@ -1,4 +1,5 @@ --- +import { ClientRouter } from "astro:transitions"; import "../styles/globals.css"; import UserMenu from "~/components/UserMenu"; import ThemeSwitch from "~/components/ThemeSwitch"; @@ -9,20 +10,19 @@ import { toJsonLdScript, type PageSeoInput, } from "@app/seo-geo"; -import { COOKIE_LANG, COOKIE_LANG_MAX_AGE, type Locale, t } from "@app/i18n"; +import { COOKIE_LANG, COOKIE_LANG_MAX_AGE, type Locale, localePath, t } from "@app/i18n"; import { getSiteSeo } from "~/lib/seo"; export interface Props { title: string; locale: Locale; brand?: string; - nav?: { label: string; href: string; n: string }[]; + /** 当前页,用于侧栏高亮 */ + active?: "home" | "library" | "browse" | "mounts" | "danmaku"; switchHref?: string; switchLabel?: string; seo?: PageSeoInput; - /** 媒体页只保留 AppShell 侧栏时隐藏模板 side-rail(含左让位) */ - hideSideRail?: boolean; - /** 媒体页隐藏模板页脚 */ + /** 播放页隐藏模板页脚 */ hideFooter?: boolean; } @@ -30,16 +30,14 @@ const { title, locale, brand, - nav: navProp, + active, switchHref, switchLabel, seo, - hideSideRail = false, hideFooter = false, } = Astro.props; const brandName = brand ?? t(locale, "meta.brand"); -const nav = navProp ?? []; const siteSeo = getSiteSeo(locale); const pageSeoInput: PageSeoInput = { @@ -52,10 +50,18 @@ const headHtml = renderHeadTagsHtml(headTags); const jsonLdHtml = toJsonLdScript(buildDefaultJsonLd(siteSeo, pageSeoInput)); const pageTitle = headTags.find((tTag) => tTag.key === "og:title")?.value ?? title; -const homeHref = locale === "zh-CN" ? "/zh-CN" : "/en"; -const libraryHref = locale === "zh-CN" ? "/zh-CN/library" : "/en/library"; -const brandHref = hideSideRail ? libraryHref : homeHref; +const homeHref = localePath(locale, "/"); +const brandHref = homeHref; const brandInitial = brandName.charAt(0) || "弹"; + +/** 全站统一侧栏导航 */ +const nav = [ + { id: "home", n: "00", label: t(locale, "nav.home"), href: homeHref }, + { id: "library", n: "01", label: t(locale, "nav.library"), href: localePath(locale, "/library") }, + { id: "browse", n: "02", label: t(locale, "nav.browse"), href: localePath(locale, "/browse") }, + { id: "mounts", n: "03", label: t(locale, "nav.mounts"), href: localePath(locale, "/mounts") }, + { id: "danmaku", n: "04", label: t(locale, "nav.danmaku"), href: localePath(locale, "/danmaku") }, +] as const; --- @@ -77,6 +83,7 @@ const brandInitial = brandName.charAt(0) || "弹"; <Fragment set:html={headHtml} /> <Fragment set:html={jsonLdHtml} /> + <ClientRouter /> <script is:inline define:vars={{ cookieName: COOKIE_LANG, maxAge: COOKIE_LANG_MAX_AGE, nextLocale: locale === "zh-CN" ? "en" : "zh-CN" }}> (function () { function apply(mode) { @@ -88,36 +95,46 @@ const brandInitial = brandName.charAt(0) || "弹"; document.documentElement.classList.toggle("light", !dark); document.documentElement.style.colorScheme = dark ? "dark" : "light"; } - var mode = "system"; - try { - var t = localStorage.getItem("app-theme"); - if (t === "light" || t === "dark" || t === "system") mode = t; - } catch (e) {} - apply(mode); - window - .matchMedia("(prefers-color-scheme: dark)") - .addEventListener("change", function () { - try { - if ((localStorage.getItem("app-theme") || "system") === "system") { + function applyFromStorage() { + var mode = "system"; + try { + var t = localStorage.getItem("app-theme"); + if (t === "light" || t === "dark" || t === "system") mode = t; + } catch (e) {} + apply(mode); + } + applyFromStorage(); + + // ClientRouter 软导航后 <html> class 会被新页覆盖,需按偏好重放主题 + document.addEventListener("astro:after-swap", applyFromStorage); + + if (!window.__fanmuChromeInit) { + window.__fanmuChromeInit = true; + window + .matchMedia("(prefers-color-scheme: dark)") + .addEventListener("change", function () { + try { + if ((localStorage.getItem("app-theme") || "system") === "system") { + apply("system"); + } + } catch (e) { apply("system"); } - } catch (e) { - apply("system"); - } - }); + }); - // 语言切换:写偏好 cookie 后由 <a> 跳转(捕获阶段) - document.addEventListener( - "click", - function (e) { - var el = e.target && e.target.closest ? e.target.closest("[data-switch-locale]") : null; - if (!el) return; - var target = el.getAttribute("data-switch-locale") || nextLocale; - document.cookie = - cookieName + "=" + target + "; path=/; max-age=" + maxAge + "; SameSite=Lax"; - }, - true, - ); + // 语言切换:写偏好 cookie 后由 <a> 跳转(捕获阶段) + document.addEventListener( + "click", + function (e) { + var el = e.target && e.target.closest ? e.target.closest("[data-switch-locale]") : null; + if (!el) return; + var target = el.getAttribute("data-switch-locale") || nextLocale; + document.cookie = + cookieName + "=" + target + "; path=/; max-age=" + maxAge + "; SameSite=Lax"; + }, + true, + ); + } })(); </script> </head> @@ -133,42 +150,40 @@ const brandInitial = brandName.charAt(0) || "弹"; <div aria-hidden="true" class="bg-vignette pointer-events-none fixed inset-0 z-0"></div> { - !hideSideRail && ( - <aside class="side-rail" aria-label={t(locale, "nav.primary")}> - <a href={brandHref} class="side-rail__brand" aria-label={brandName}> - <span - class="brand-signal flex size-8 items-center justify-center text-[12px] font-bold" - style="background: var(--edge); color: #101110;" + <aside class="side-rail" aria-label={t(locale, "nav.primary")}> + <a href={brandHref} class="side-rail__brand" aria-label={brandName}> + <span + class="brand-signal flex size-8 items-center justify-center text-[12px] font-bold" + style="background: var(--edge); color: #101110;" + > + {brandInitial} + </span> + <span class="side-rail__brand-text">{brandName}</span> + </a> + <div class="side-rail__rule" aria-hidden="true"></div> + <nav class="side-rail__nav"> + {nav.map((item) => ( + <a + href={item.href} + class:list={["side-rail__link", { "is-active": active === item.id }]} + aria-current={active === item.id ? "page" : undefined} > - {brandInitial} - </span> - <span class="side-rail__brand-text">{brandName}</span> - </a> - <div class="side-rail__rule" aria-hidden="true"></div> - <nav class="side-rail__nav"> - <a href={brandHref} class="side-rail__link is-home"> - <span class="u-mono">00</span> - <span>{t(locale, "nav.home")}</span> + <span class="u-mono">{item.n}</span> + <span>{item.label}</span> </a> - {nav.map((item) => ( - <a href={item.href} class="side-rail__link"> - <span class="u-mono">{item.n}</span> - <span>{item.label}</span> - </a> - ))} - </nav> - <div class="side-rail__rule" aria-hidden="true"></div> - <div class="side-rail__foot"> - <ThemeSwitch client:visible /> - </div> - </aside> - ) + ))} + </nav> + <div class="side-rail__rule" aria-hidden="true"></div> + <div class="side-rail__foot"> + <ThemeSwitch client:only="react" /> + </div> + </aside> } - <div class:list={["site-shell", { "site-shell--no-rail": hideSideRail }]}> + <div class="site-shell"> <header class="site-header"> <div class="site-header__inner"> - <a href={brandHref} class="site-header__brand md:hidden" aria-label={brandName}> + <a href={brandHref} class="site-header__brand lg:hidden" aria-label={brandName}> <span class="brand-signal flex size-7 items-center justify-center text-[11px] font-bold" style="background: var(--edge); color: #101110;" @@ -180,7 +195,11 @@ const brandInitial = brandName.charAt(0) || "弹"; <nav class="site-header__nav" aria-label={t(locale, "nav.primary")}> { nav.map((item) => ( - <a href={item.href} class="u-linelink"> + <a + href={item.href} + class:list={["u-linelink", { "is-active": active === item.id }]} + aria-current={active === item.id ? "page" : undefined} + > <span class="u-mono">{item.n}</span> <span>{item.label}</span> </a> @@ -201,10 +220,10 @@ const brandInitial = brandName.charAt(0) || "弹"; </a> ) } - <span class={hideSideRail ? "" : "md:hidden"}> - <ThemeSwitch client:visible /> + <span class="lg:hidden"> + <ThemeSwitch client:only="react" /> </span> - <UserMenu client:visible locale={locale} /> + <UserMenu client:only="react" locale={locale} /> </div> </div> <div class="site-header__dash" aria-hidden="true"></div> diff --git a/app/web/src/lib/danmaku-render.ts b/app/web/src/lib/danmaku-render.ts index 59f777b..1530aaa 100644 --- a/app/web/src/lib/danmaku-render.ts +++ b/app/web/src/lib/danmaku-render.ts @@ -1,95 +1,49 @@ -export type DanmakuItem = { time: number; text: string; mode: number }; +export type DanmakuItem = { + time: number; + text: string; + /** 0 滚动,1 顶部,2 底部(artplayer-plugin-danmuku) */ + mode: 0 | 1 | 2; + color?: string | undefined; +}; +/** B 站 p 属性 mode → artplayer 插件 mode */ +function toPluginMode(raw: number): 0 | 1 | 2 { + // 1/2/3 滚动,4 底部,5 顶部 + if (raw === 4) return 2; + if (raw === 5) return 1; + return 0; +} + +function toHexColor(decimal: number): string | undefined { + if (!Number.isFinite(decimal) || decimal < 0) return undefined; + return `#${decimal.toString(16).padStart(6, "0").toUpperCase()}`; +} + +/** 解析 B 站弹幕 XML(含颜色/模式),供 artplayer-plugin-danmuku 使用 */ export function parseDanmakuXml(xml: string): DanmakuItem[] { const doc = new DOMParser().parseFromString(xml, "application/xml"); const nodes = Array.from(doc.querySelectorAll("d")); const out: DanmakuItem[] = []; for (const n of nodes) { const p = n.getAttribute("p") ?? ""; - const [t, mode] = p.split(","); + const [t, modeRaw, , colorRaw] = p.split(","); const time = Number(t); if (!Number.isFinite(time)) continue; - out.push({ time, text: n.textContent ?? "", mode: Number(mode) || 1 }); + const text = (n.textContent ?? "").trim(); + if (!text) continue; + out.push({ + time, + text, + mode: toPluginMode(Number(modeRaw) || 1), + color: toHexColor(Number(colorRaw)), + }); } return out.sort((a, b) => a.time - b.time); } -export type DanmakuLoopOptions = { - opacity?: number | undefined; - /** 每秒横向位移 px */ - speed?: number | undefined; -}; - -export function startDanmakuLoop( - container: HTMLElement, - getItems: () => DanmakuItem[], - getCurrentTime: () => number, - options?: DanmakuLoopOptions, -): () => void { - const layer = document.createElement("div"); - layer.className = "absolute inset-0 overflow-hidden pointer-events-none"; - layer.style.position = "absolute"; - layer.style.zIndex = "5"; - const opacity = options?.opacity; - if (typeof opacity === "number") { - layer.style.opacity = String(Math.min(1, Math.max(0, opacity))); - } - container.style.position = container.style.position || "relative"; - container.appendChild(layer); - - const active: { el: HTMLDivElement; start: number }[] = []; - const speed = options?.speed ?? 160; - let raf = 0; - let cursor = 0; - let lastTime = 0; - - const clearActive = () => { - for (const a of active) a.el.remove(); - active.length = 0; - }; - - const tick = () => { - const now = getCurrentTime(); - const items = getItems(); - // 进度回跳时重置游标,避免一次性涌出全部历史弹幕 - if (now + 0.5 < lastTime) { - cursor = 0; - clearActive(); - } - lastTime = now; - while (cursor < items.length) { - const it = items[cursor]; - if (!it || it.time > now) break; - cursor += 1; - // 快进/加载导致的过期弹幕直接丢弃,防止一帧内刷屏 - if (it.time < now - 2) continue; - const el = document.createElement("div"); - el.textContent = it.text; - el.style.cssText = - "position:absolute;left:100%;white-space:nowrap;color:#fff;font-size:22px;text-shadow:0 1px 2px #000;will-change:transform;"; - const maxY = Math.max(0, container.clientHeight - 40); - el.style.top = `${Math.random() * maxY}px`; - layer.appendChild(el); - active.push({ el, start: performance.now() }); - } - const t = performance.now(); - for (let i = active.length - 1; i >= 0; i -= 1) { - const a = active[i]; - if (!a) continue; - const elapsed = (t - a.start) / 1000; - const x = container.clientWidth - elapsed * speed; - a.el.style.transform = `translateX(${x - container.clientWidth}px)`; - if (x < -a.el.offsetWidth) { - a.el.remove(); - active.splice(i, 1); - } - } - raf = requestAnimationFrame(tick); - }; - raf = requestAnimationFrame(tick); - return () => { - cancelAnimationFrame(raf); - clearActive(); - layer.remove(); - }; +/** density < 1 时按比例抽稀,避免同屏过密 */ +export function thinDanmaku(items: DanmakuItem[], density: number): DanmakuItem[] { + if (density >= 1) return items; + const keepEvery = Math.max(1, Math.round(1 / density)); + return items.filter((_, i) => i % keepEvery === 0); } diff --git a/app/web/src/lib/nav.ts b/app/web/src/lib/nav.ts new file mode 100644 index 0000000..63c8181 --- /dev/null +++ b/app/web/src/lib/nav.ts @@ -0,0 +1,23 @@ +import { navigate } from "astro:transitions/client"; + +/** 站内软导航(ClientRouter),避免整页 GET。 */ +export function go(href: string): void { + void navigate(href); +} + +/** 读取当前 URL 查询参数(浏览器侧)。 */ +export function queryParam(name: string): string | null { + if (typeof window === "undefined") return null; + return new URLSearchParams(window.location.search).get(name); +} + +/** 更新查询参数(不刷新,便于刷新/分享后停留在同一选集)。 */ +export function setQueryParams(params: Record<string, string | null>): void { + if (typeof window === "undefined") return; + const url = new URL(window.location.href); + for (const [key, value] of Object.entries(params)) { + if (value == null) url.searchParams.delete(key); + else url.searchParams.set(key, value); + } + window.history.replaceState(null, "", url.toString()); +} diff --git a/app/web/src/lib/seo.ts b/app/web/src/lib/seo.ts index 36f3dbe..99e732f 100644 --- a/app/web/src/lib/seo.ts +++ b/app/web/src/lib/seo.ts @@ -29,11 +29,11 @@ export function getSiteSeo(locale: Locale): SiteSeoConfig { organization: { name, url: siteUrl }, llms: { summary: isZh - ? "弹弹 Web:多用户 Web 媒体中心。WebDAV 挂载、媒体库扫描、Bangumi 刮削、ArtPlayer 播放与弹弹play 开放弹幕网络中继,本地 XML 兜底。" - : "DanDan Web: multi-user web media hub. WebDAV mounts, library scan, Bangumi scraping, ArtPlayer playback, dandanplay open danmaku relay with local XML fallback.", + ? "帆幕:私有影音库与弹幕观影。WebDAV 挂载、媒体库扫描、Bangumi 刮削、ArtPlayer 播放与弹弹play 开放弹幕网络中继,本地 XML 兜底。" + : "Fanmu: private media library with danmaku. WebDAV mounts, library scan, Bangumi scraping, ArtPlayer playback, dandanplay open danmaku relay with local XML fallback.", details: [ isZh - ? "分层:types → models → db → dao → trpc → web。媒体域按垂直切片扩展。" + ? "分层:types → models → db → dao → trpc → web。媒体域按业务分层扩展。" : "Layers: types → models → db → dao → trpc → web. Extend the media domain as a vertical slice.", isZh ? "挂载凭据仅存服务端加密;浏览器不直连网盘密码。" diff --git a/app/web/src/lib/trpc-error.ts b/app/web/src/lib/trpc-error.ts new file mode 100644 index 0000000..2da4384 --- /dev/null +++ b/app/web/src/lib/trpc-error.ts @@ -0,0 +1,26 @@ +/** tRPC 错误 → 用户可读文案:提取 zod 数组消息、兜底 HTML/网络异常。 */ +export function formatTrpcError(err: unknown, fallback: string): string { + const raw = err instanceof Error ? err.message : typeof err === "string" ? err : ""; + if (!raw) return fallback; + if (raw.startsWith("<") || raw.includes("<!DOCTYPE")) { + return "服务暂时不可用,请稍后重试"; + } + try { + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) { + const msgs = parsed + .map((e) => + e && typeof e === "object" ? (e as { message?: unknown }).message : null, + ) + .filter((m): m is string => typeof m === "string" && m.length > 0); + if (msgs.length > 0) return msgs.join(";"); + } + if (parsed && typeof parsed === "object" && "message" in parsed) { + const m = (parsed as { message?: unknown }).message; + if (typeof m === "string" && m) return m; + } + } catch { + /* 非 JSON,按原文处理 */ + } + return raw; +} diff --git a/app/web/src/lib/trpc.ts b/app/web/src/lib/trpc.ts index f973736..9fe4cd2 100644 --- a/app/web/src/lib/trpc.ts +++ b/app/web/src/lib/trpc.ts @@ -8,6 +8,26 @@ import type { AppRouter } from "@app/trpc"; export const trpc = createTRPCReact<AppRouter>(); +/** 网关/框架可能对 /api/trpc 返回 HTML 错误页:转换成 JSON 错误,避免客户端 JSON.parse 抛原始异常给用户 */ +async function safeFetch(input: RequestInfo | URL, init?: unknown): Promise<Response> { + const res = await fetch(input, init as RequestInit | undefined); + const contentType = res.headers.get("content-type") ?? ""; + if (!res.ok && !contentType.includes("application/json")) { + const message = res.status >= 500 ? "服务暂时不可用,请稍后重试" : "请求失败,请重试"; + return new Response( + JSON.stringify({ + error: { + message, + code: -32603, + data: { code: "INTERNAL_SERVER_ERROR", httpStatus: res.status }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + return res; +} + export function createTRPCReactClient() { return trpc.createClient({ links: [ @@ -15,6 +35,7 @@ export function createTRPCReactClient() { url: "/api/trpc", // 便于服务端日志区分 island 流量 headers: () => ({ "x-trpc-source": "react-island" }), + fetch: safeFetch, }), ], }); diff --git a/app/web/src/middleware.ts b/app/web/src/middleware.ts index 589ddfd..9e34647 100644 --- a/app/web/src/middleware.ts +++ b/app/web/src/middleware.ts @@ -69,7 +69,8 @@ export const onRequest = defineMiddleware(async (context, next) => { // 小写 zh-cn → 规范 zh-CN(保留 rest) const seg = path.split("/").filter(Boolean)[0]; if (seg && seg.toLowerCase() === "zh-cn" && seg !== "zh-CN") { - const rest = path.slice(seg.length) || "/"; + // 去掉 "/seg" 本身,剩余为 "/rest"(off-by-one 会产生 /zh-CNn) + const rest = path.slice(seg.length + 1) || "/"; return context.redirect(`/zh-CN${rest === "/" ? "" : rest}`); } diff --git a/app/web/src/pages/404.astro b/app/web/src/pages/404.astro index 589a452..235c8fc 100644 --- a/app/web/src/pages/404.astro +++ b/app/web/src/pages/404.astro @@ -1,12 +1,16 @@ --- -import { COOKIE_LANG, resolvePreferredLocale } from "@app/i18n"; +import { COOKIE_LANG, isLocale, resolvePreferredLocale } from "@app/i18n"; import ErrorViewPage from "~/views/error.astro"; -// Astro 全局 404:按 cookie / Accept-Language 选 locale(无 [locale] 段) -const locale = resolvePreferredLocale( - Astro.cookies.get(COOKIE_LANG)?.value, - Astro.request.headers.get("accept-language"), -); +// Astro 全局 404:优先跟随 URL 的 locale 段(/zh-CN/* 显示中文),否则按 cookie / Accept-Language +const seg = Astro.url.pathname.split("/")[1]; +const pathLocale = seg && isLocale(seg) ? seg : null; +const locale = + pathLocale ?? + resolvePreferredLocale( + Astro.cookies.get(COOKIE_LANG)?.value, + Astro.request.headers.get("accept-language"), + ); Astro.response.status = 404; --- diff --git a/app/web/src/pages/[locale]/dashboard.astro b/app/web/src/pages/[locale]/dashboard.astro index 51ab521..f4cb420 100644 --- a/app/web/src/pages/[locale]/dashboard.astro +++ b/app/web/src/pages/[locale]/dashboard.astro @@ -1,6 +1,5 @@ --- import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n"; -import Dashboard from "~/views/dashboard.astro"; const raw = Astro.params["locale"]; const normalized = raw === "zh-cn" ? "zh-CN" : raw; @@ -14,6 +13,8 @@ if (!normalized || !isLocale(normalized)) { } const locale: Locale = normalized; ---- -<Dashboard locale={locale} /> +// /dashboard 是旧路由:登录态(middleware 已鉴权)在页面渲染前重定向到媒体库。 +// 必须在页面层 redirect —— 放在子组件里会与流式渲染冲突抛 ResponseSentError。 +return Astro.redirect(localePath(locale, "/library")); +--- diff --git a/app/web/src/pages/api/stream.ts b/app/web/src/pages/api/stream.ts index 7885e74..4c64ab7 100644 --- a/app/web/src/pages/api/stream.ts +++ b/app/web/src/pages/api/stream.ts @@ -1,7 +1,7 @@ /** 认证后的 Range 流代理:鉴权在本路由,webdav 细节封装在 @app/trpc 的 openRemoteFileStream。 */ import type { APIRoute } from "astro"; import { COOKIE_SESSION } from "@app/types"; -import { authService, openRemoteFileStream } from "@app/trpc"; +import { authService, openRemoteFileStream, StreamSourceError } from "@app/trpc"; export const prerender = false; @@ -25,6 +25,9 @@ export const GET: APIRoute = async ({ request, cookies, url }) => { headers: result.headers, }); } catch (e) { + if (e instanceof StreamSourceError) { + return new Response(e.message, { status: e.status }); + } const msg = e instanceof Error ? e.message : "stream failed"; return new Response(msg, { status: 502 }); } diff --git a/app/web/src/pages/api/users.ts b/app/web/src/pages/api/users.ts index 45a18c4..05d7a2d 100644 --- a/app/web/src/pages/api/users.ts +++ b/app/web/src/pages/api/users.ts @@ -5,13 +5,11 @@ export const prerender = false; export const GET: APIRoute = async ({ request }) => { // 内部转发到 tRPC handler(同进程内,避免跨网络) + // tRPC v11 的 query 走 GET,input 放 query 参数 const url = new URL(request.url); url.pathname = "/api/trpc/user.list"; - const req = new Request(url.toString(), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ json: { limit: 20, offset: 0 } }), - }); + url.search = `?input=${encodeURIComponent(JSON.stringify({ limit: 20, offset: 0 }))}`; + const req = new Request(url.toString(), { method: "GET" }); // 调用同一个 Astro 进程的 tRPC endpoint const res = await fetch(req); diff --git a/app/web/src/pages/llms-full.txt.ts b/app/web/src/pages/llms-full.txt.ts index f3003bf..e10a28d 100644 --- a/app/web/src/pages/llms-full.txt.ts +++ b/app/web/src/pages/llms-full.txt.ts @@ -33,7 +33,7 @@ function buildFor(locale: Locale): string { summary: t(locale, "home.layerBody"), points: [ "多用户:每人独立媒体库与播放进度", - "媒体页 shadcn Dashboard 导航:媒体库 / 浏览 / 挂载 / 弹幕设置", + "统一左侧边栏导航:首页 / 媒体库 / 文件浏览 / 挂载管理 / 弹幕设置", ], }, ], diff --git a/app/web/src/styles/globals.css b/app/web/src/styles/globals.css index a0091cb..61e08dd 100644 --- a/app/web/src/styles/globals.css +++ b/app/web/src/styles/globals.css @@ -1,6 +1,9 @@ @import "tailwindcss"; @import "@app/design-tokens/tokens.css"; +/* 扫描 monorepo 里的 UI 包,避免 dropdown 等组件类未生成 */ +@source "../../../../packages/ui/src"; + *, *::before, *::after { @@ -8,6 +11,126 @@ border-color: var(--line); } +/* 原生控件跟品牌色,避免浏览器默认蓝 */ +input, +select, +textarea, +button { + font: inherit; + accent-color: var(--edge); +} + +select { + appearance: none; + background-image: + linear-gradient(45deg, transparent 50%, var(--ink-3) 50%), + linear-gradient(135deg, var(--ink-3) 50%, transparent 50%); + background-position: + calc(100% - 16px) calc(50% + 2px), + calc(100% - 11px) calc(50% + 2px); + background-size: + 5px 5px, + 5px 5px; + background-repeat: no-repeat; + padding-right: 2rem; +} + +input[type="range"] { + -webkit-appearance: none; + appearance: none; + height: 22px; + cursor: pointer; + background: transparent; +} +input[type="range"]::-webkit-slider-runnable-track { + height: 6px; + border-radius: 3px; + background: var(--line); +} +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + margin-top: -5px; + border: none; + border-radius: 50%; + background: var(--edge); + box-shadow: 0 1px 3px color-mix(in srgb, #000 30%, transparent); +} +input[type="range"]::-moz-range-track { + height: 6px; + border-radius: 3px; + background: var(--line); +} +input[type="range"]::-moz-range-thumb { + width: 16px; + height: 16px; + border: none; + border-radius: 50%; + background: var(--edge); +} + +input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; + accent-color: var(--edge); + /* Chrome 部分主题下忽略 accent-color,强制品牌色勾选框 */ + appearance: none; + -webkit-appearance: none; + border: 1px solid var(--line-strong); + border-radius: 4px; + background: var(--panel); + display: inline-grid; + place-content: center; +} +input[type="checkbox"]::before { + content: ""; + width: 10px; + height: 10px; + transform: scale(0); + transition: transform 0.12s ease; + background: #101110; + clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); +} +input[type="checkbox"]:checked { + background: var(--edge); + border-color: var(--edge); +} +input[type="checkbox"]:checked::before { + transform: scale(1); +} + +/* 空态:居中卡片,避免大片死白 */ +.u-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + min-height: 220px; + padding: 2.5rem 1.5rem; + text-align: center; + border: 1px dashed var(--line); + border-radius: 8px; + background: color-mix(in srgb, var(--panel) 55%, transparent); + color: var(--ink-2); + font-size: 14px; +} +.u-empty__title { + color: var(--ink); + font-size: 15px; + font-weight: 600; +} +.u-empty__actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: center; + margin-top: 0.35rem; +} + html { font-family: var(--font-sans); scroll-behavior: smooth; @@ -241,19 +364,25 @@ html.has-reveal astro-island .u-reveal { transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1); } .u-linelink:hover, -.u-linelink:focus-visible { +.u-linelink:focus-visible, +.u-linelink.is-active { color: var(--ink); } .u-linelink:hover::after, .u-linelink:focus-visible::after { transform: scaleX(1); } +.u-linelink.is-active::after { + transform: scaleX(1); + height: 2px; +} .u-linelink .u-mono { font-size: 10px; letter-spacing: 0.12em; color: var(--ink-3); } -.u-linelink:hover .u-mono { +.u-linelink:hover .u-mono, +.u-linelink.is-active .u-mono { color: var(--edge-as-text); } @@ -302,7 +431,8 @@ html.has-reveal astro-island .u-reveal { /* —— 卡片:5px 圆角 + 轻阴影 —— */ .u-card { - background: var(--panel); + position: relative; + background: color-mix(in srgb, var(--panel) 92%, var(--ink) 2%); border: 1px solid var(--line); border-radius: 5px; box-shadow: 0 7px 12px color-mix(in srgb, #000 18%, transparent); @@ -356,6 +486,24 @@ html.has-reveal astro-island .u-reveal { letter-spacing: 0.08em; } +/* 流水线读出条 */ +.u-hud-strip { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + padding: 0.65rem 0.9rem; + border: 1px solid var(--line); + border-radius: 5px; + background: color-mix(in srgb, var(--panel) 70%, transparent); + color: var(--ink-3); + font-size: 10px; + letter-spacing: 0.18em; +} +.u-hud-strip .u-mono { + flex-shrink: 0; +} + /* —— 左侧固定竖栏 —— */ .side-rail { display: none; @@ -431,13 +579,27 @@ html.has-reveal astro-island .u-reveal { } .side-rail__link:hover, .side-rail__link:focus-visible { - background: color-mix(in srgb, var(--edge) 10%, transparent); - border-color: color-mix(in srgb, var(--edge) 35%, var(--line)); + background: color-mix(in srgb, var(--edge) 8%, transparent); + border-color: color-mix(in srgb, var(--edge) 30%, var(--line)); color: var(--ink); } -.side-rail__link:hover .u-mono { +.side-rail__link.is-active { + background: color-mix(in srgb, var(--edge) 14%, var(--sink)); + border-color: color-mix(in srgb, var(--edge) 45%, var(--line)); + color: var(--ink); + box-shadow: inset 2px 0 0 var(--edge); +} +.side-rail__link:hover .u-mono, +.side-rail__link:focus-visible .u-mono { color: var(--edge-as-text); } +.side-rail__link.is-active .u-mono { + color: var(--edge-as-text); +} +.light .side-rail__link.is-active { + background: color-mix(in srgb, var(--edge) 12%, var(--panel)); + box-shadow: inset 2px 0 0 var(--edge-deep); +} .side-rail__foot { display: flex; flex-direction: column; @@ -471,18 +633,12 @@ html.has-reveal astro-island .u-reveal { padding-left: 8.5rem; padding-right: 3.5rem; } - .site-shell--no-rail { - padding-left: 3.5rem; - } } @media (min-width: 1280px) { .site-shell { padding-left: 10.5rem; padding-right: 5rem; } - .site-shell--no-rail { - padding-left: 5rem; - } } .site-shell--page { height: 100dvh; @@ -541,18 +697,25 @@ html.has-reveal astro-island .u-reveal { text-decoration: none; color: var(--ink); } +/* 桌面已有侧栏品牌,顶栏品牌隐藏(Tailwind lg:hidden 会被上面的 display:flex 压掉) */ +@media (min-width: 1024px) { + .site-header__brand { + display: none; + } +} .site-header__nav { - display: none; + display: flex; align-items: center; gap: 1.35rem; font-size: 13px; + overflow-x: auto; + min-width: 0; + scrollbar-width: none; } -@media (min-width: 768px) { - .site-header__nav { - display: flex; - } +.site-header__nav::-webkit-scrollbar { + display: none; } -/* 桌面已有侧栏时,顶栏可隐藏重复分类导航,仅保留品牌/操作 */ +/* 桌面已有侧栏时,顶栏隐藏重复分类导航,仅保留品牌/操作 */ @media (min-width: 1024px) { .site-header__nav { display: none; diff --git a/app/web/src/views/auth.astro b/app/web/src/views/auth.astro index 5d3b9f2..8a4342a 100644 --- a/app/web/src/views/auth.astro +++ b/app/web/src/views/auth.astro @@ -1,7 +1,6 @@ --- import { alternatePath, type Locale, t } from "@app/i18n"; -import { Toaster } from "@app/ui"; -import AuthForm from "~/components/AuthForm"; +import AuthBody from "~/components/pages/AuthBody"; import AppLayout from "~/layouts/AppLayout.astro"; export interface Props { @@ -24,18 +23,16 @@ const titleKey = : mode === "reset" ? "auth.reset.title" : "auth.settings.title"; + +const pageTitle = `${t(locale, titleKey as never)} · ${t(locale, "meta.brand")}`; --- <AppLayout - title={`${t(locale, titleKey as never)} · ${t(locale, "meta.brand")}`} + title={pageTitle} locale={locale} - nav={[]} switchHref={alternatePath(Astro.url.pathname, other)} switchLabel={t(other, `locale.${other}` as never)} - seo={{ title: `${t(locale, titleKey as never)} · ${t(locale, "meta.brand")}`, path: Astro.url.pathname, robots: ["noindex", "nofollow"] }} + seo={{ title: pageTitle, path: Astro.url.pathname, robots: ["noindex", "nofollow"] }} > - <div class="flex min-h-[60vh] items-center justify-center px-4 py-16"> - <AuthForm client:load locale={locale} mode={mode} next={next} defaultToken={token} /> - </div> - <Toaster client:load /> + <AuthBody client:load locale={locale} mode={mode} next={next} token={token} /> </AppLayout> diff --git a/app/web/src/views/browse.astro b/app/web/src/views/browse.astro index 35cf700..629dca4 100644 --- a/app/web/src/views/browse.astro +++ b/app/web/src/views/browse.astro @@ -1,7 +1,6 @@ --- import { alternatePath, type Locale, t } from "@app/i18n"; import { Toaster } from "@app/ui"; -import { AppShell } from "~/components/AppShell"; import { BrowseBody } from "~/components/pages/BrowseBody"; import AppLayout from "~/layouts/AppLayout.astro"; @@ -11,22 +10,12 @@ export interface Props { const { locale } = Astro.props; const other: Locale = locale === "zh-CN" ? "en" : "zh-CN"; -const labels: Record<string, string> = { - "meta.brand": t(locale, "meta.brand"), - "meta.product": t(locale, "meta.product"), - "nav.library": t(locale, "nav.library"), - "nav.browse": t(locale, "nav.browse"), - "nav.mounts": t(locale, "nav.mounts"), - "nav.danmaku": t(locale, "nav.danmaku"), -}; --- <AppLayout title={t(locale, "browse.title")} locale={locale} - nav={[]} - hideSideRail - hideFooter + active="browse" brand={t(locale, "meta.product")} switchHref={alternatePath(Astro.url.pathname, other)} switchLabel={t(other, `locale.${other}` as never)} @@ -36,9 +25,6 @@ const labels: Record<string, string> = { robots: ["noindex", "nofollow"], }} > - <AppShell locale={locale} active="browse" labels={labels}> - <BrowseBody locale={locale} client:load /> - </AppShell> + <BrowseBody locale={locale} client:load /> <Toaster client:load /> </AppLayout> - diff --git a/app/web/src/views/danmaku.astro b/app/web/src/views/danmaku.astro index f072ad5..478a06b 100644 --- a/app/web/src/views/danmaku.astro +++ b/app/web/src/views/danmaku.astro @@ -1,7 +1,6 @@ --- import { alternatePath, type Locale, t } from "@app/i18n"; import { Toaster } from "@app/ui"; -import { AppShell } from "~/components/AppShell"; import { DanmakuSettingsBody } from "~/components/pages/DanmakuSettingsBody"; import AppLayout from "~/layouts/AppLayout.astro"; @@ -11,22 +10,12 @@ export interface Props { const { locale } = Astro.props; const other: Locale = locale === "zh-CN" ? "en" : "zh-CN"; -const labels: Record<string, string> = { - "meta.brand": t(locale, "meta.brand"), - "meta.product": t(locale, "meta.product"), - "nav.library": t(locale, "nav.library"), - "nav.browse": t(locale, "nav.browse"), - "nav.mounts": t(locale, "nav.mounts"), - "nav.danmaku": t(locale, "nav.danmaku"), -}; --- <AppLayout title={t(locale, "danmaku.title")} locale={locale} - nav={[]} - hideSideRail - hideFooter + active="danmaku" brand={t(locale, "meta.product")} switchHref={alternatePath(Astro.url.pathname, other)} switchLabel={t(other, `locale.${other}` as never)} @@ -36,8 +25,6 @@ const labels: Record<string, string> = { robots: ["noindex", "nofollow"], }} > - <AppShell locale={locale} active="danmaku" labels={labels}> - <DanmakuSettingsBody locale={locale} client:load /> - </AppShell> + <DanmakuSettingsBody locale={locale} client:load /> <Toaster client:load /> </AppLayout> diff --git a/app/web/src/views/dashboard.astro b/app/web/src/views/dashboard.astro deleted file mode 100644 index 99642bf..0000000 --- a/app/web/src/views/dashboard.astro +++ /dev/null @@ -1,29 +0,0 @@ ---- -import { alternatePath, type Locale, localePath, t } from "@app/i18n"; -import AppLayout from "~/layouts/AppLayout.astro"; - -export interface Props { - locale: Locale; -} - -const { locale } = Astro.props; -const other: Locale = locale === "zh-CN" ? "en" : "zh-CN"; - -// 产品:/dashboard 即媒体库(保留路由以免旧链接与 e2e 404) -return Astro.redirect(localePath(locale, "/library")); ---- - -<AppLayout - title={t(locale, "dashboard.title")} - locale={locale} - nav={[]} - switchHref={alternatePath(Astro.url.pathname, other)} - switchLabel={t(other, `locale.${other}` as never)} - seo={{ - title: `${t(locale, "dashboard.title")} · ${t(locale, "meta.brand")}`, - path: Astro.url.pathname, - robots: ["noindex", "nofollow"], - }} -> - <p class="py-10 text-sm text-muted-foreground">{t(locale, "dashboard.desc")}</p> -</AppLayout> diff --git a/app/web/src/views/error.astro b/app/web/src/views/error.astro index f596432..0ce73d1 100644 --- a/app/web/src/views/error.astro +++ b/app/web/src/views/error.astro @@ -1,6 +1,6 @@ --- import { alternatePath, type Locale, t } from "@app/i18n"; -import { ErrorView } from "~/components/errors/ErrorView"; +import ErrorBody from "~/components/pages/ErrorBody"; import { createAppError, type AppErrorStatus } from "~/lib/errors"; import AppLayout from "~/layouts/AppLayout.astro"; @@ -12,7 +12,6 @@ export interface Props { const { locale, status } = Astro.props; const other: Locale = locale === "zh-CN" ? "en" : "zh-CN"; const error = createAppError(status); -const homeHref = locale === "zh-CN" ? "/zh-CN" : "/en"; const titleKey = `error.${error.kind}.title` as never; const pageTitle = t(locale, titleKey); --- @@ -20,7 +19,6 @@ const pageTitle = t(locale, titleKey); <AppLayout title={`${pageTitle} · ${t(locale, "meta.brand")}`} locale={locale} - nav={[]} switchHref={alternatePath(Astro.url.pathname, other)} switchLabel={t(other, `locale.${other}` as never)} seo={{ @@ -29,7 +27,5 @@ const pageTitle = t(locale, titleKey); robots: ["noindex", "nofollow"], }} > - <div class="flex min-h-[55vh] items-center justify-center px-4 py-16"> - <ErrorView error={error} locale={locale} homeHref={homeHref} /> - </div> + <ErrorBody client:load locale={locale} status={status} /> </AppLayout> diff --git a/app/web/src/views/home.astro b/app/web/src/views/home.astro index efa3eff..09fcbde 100644 --- a/app/web/src/views/home.astro +++ b/app/web/src/views/home.astro @@ -15,7 +15,7 @@ const switchHref = alternatePath(Astro.url.pathname, otherLocale); <AppLayout title={t(locale, "home.title")} locale={locale} - nav={[]} + active="home" switchHref={switchHref} switchLabel={t(otherLocale, `locale.${otherLocale}` as never)} > diff --git a/app/web/src/views/library.astro b/app/web/src/views/library.astro index 4d84c71..25a7e3b 100644 --- a/app/web/src/views/library.astro +++ b/app/web/src/views/library.astro @@ -1,7 +1,6 @@ --- import { alternatePath, type Locale, t } from "@app/i18n"; import { Toaster } from "@app/ui"; -import { AppShell } from "~/components/AppShell"; import { LibraryBody } from "~/components/pages/LibraryBody"; import AppLayout from "~/layouts/AppLayout.astro"; @@ -11,22 +10,12 @@ export interface Props { const { locale } = Astro.props; const other: Locale = locale === "zh-CN" ? "en" : "zh-CN"; -const labels: Record<string, string> = { - "meta.brand": t(locale, "meta.brand"), - "meta.product": t(locale, "meta.product"), - "nav.library": t(locale, "nav.library"), - "nav.browse": t(locale, "nav.browse"), - "nav.mounts": t(locale, "nav.mounts"), - "nav.danmaku": t(locale, "nav.danmaku"), -}; --- <AppLayout title={t(locale, "library.title")} locale={locale} - nav={[]} - hideSideRail - hideFooter + active="library" brand={t(locale, "meta.product")} switchHref={alternatePath(Astro.url.pathname, other)} switchLabel={t(other, `locale.${other}` as never)} @@ -36,8 +25,6 @@ const labels: Record<string, string> = { robots: ["noindex", "nofollow"], }} > - <AppShell locale={locale} active="library" labels={labels}> - <LibraryBody locale={locale} client:load /> - </AppShell> + <LibraryBody locale={locale} client:load /> <Toaster client:load /> </AppLayout> diff --git a/app/web/src/views/mounts.astro b/app/web/src/views/mounts.astro index cf64776..2dd7086 100644 --- a/app/web/src/views/mounts.astro +++ b/app/web/src/views/mounts.astro @@ -1,7 +1,6 @@ --- import { alternatePath, type Locale, t } from "@app/i18n"; import { Toaster } from "@app/ui"; -import { AppShell } from "~/components/AppShell"; import { MountsBody } from "~/components/pages/MountsBody"; import AppLayout from "~/layouts/AppLayout.astro"; @@ -11,22 +10,12 @@ export interface Props { const { locale } = Astro.props; const other: Locale = locale === "zh-CN" ? "en" : "zh-CN"; -const labels: Record<string, string> = { - "meta.brand": t(locale, "meta.brand"), - "meta.product": t(locale, "meta.product"), - "nav.library": t(locale, "nav.library"), - "nav.browse": t(locale, "nav.browse"), - "nav.mounts": t(locale, "nav.mounts"), - "nav.danmaku": t(locale, "nav.danmaku"), -}; --- <AppLayout title={t(locale, "mounts.title")} locale={locale} - nav={[]} - hideSideRail - hideFooter + active="mounts" brand={t(locale, "meta.product")} switchHref={alternatePath(Astro.url.pathname, other)} switchLabel={t(other, `locale.${other}` as never)} @@ -36,8 +25,6 @@ const labels: Record<string, string> = { robots: ["noindex", "nofollow"], }} > - <AppShell locale={locale} active="mounts" labels={labels}> - <MountsBody locale={locale} client:load /> - </AppShell> + <MountsBody locale={locale} client:load /> <Toaster client:load /> </AppLayout> diff --git a/app/web/src/views/watch.astro b/app/web/src/views/watch.astro index b1d82a3..ce62730 100644 --- a/app/web/src/views/watch.astro +++ b/app/web/src/views/watch.astro @@ -1,7 +1,6 @@ --- import { alternatePath, type Locale, t } from "@app/i18n"; import { Toaster } from "@app/ui"; -import { AppShell } from "~/components/AppShell"; import { WatchBody } from "~/components/pages/WatchBody"; import AppLayout from "~/layouts/AppLayout.astro"; @@ -12,23 +11,13 @@ export interface Props { const { locale, mediaItemId } = Astro.props; const other: Locale = locale === "zh-CN" ? "en" : "zh-CN"; -const labels: Record<string, string> = { - "meta.brand": t(locale, "meta.brand"), - "meta.product": t(locale, "meta.product"), - "nav.library": t(locale, "nav.library"), - "nav.browse": t(locale, "nav.browse"), - "nav.mounts": t(locale, "nav.mounts"), - "nav.danmaku": t(locale, "nav.danmaku"), -}; --- <AppLayout title={t(locale, "watch.title")} locale={locale} - nav={[]} - hideSideRail - hideFooter brand={t(locale, "meta.product")} + hideFooter switchHref={alternatePath(Astro.url.pathname, other)} switchLabel={t(other, `locale.${other}` as never)} seo={{ @@ -37,8 +26,6 @@ const labels: Record<string, string> = { robots: ["noindex", "nofollow"], }} > - <AppShell locale={locale} active="watch" labels={labels}> - <WatchBody locale={locale} mediaItemId={mediaItemId} client:load /> - </AppShell> + <WatchBody locale={locale} mediaItemId={mediaItemId} client:load /> <Toaster client:load /> </AppLayout> diff --git a/assets/brand/fanmu-logo-v2.png b/assets/brand/fanmu-logo-v2.png new file mode 100644 index 0000000..5b4252b Binary files /dev/null and b/assets/brand/fanmu-logo-v2.png differ diff --git a/assets/brand/fanmu-logo.png b/assets/brand/fanmu-logo.png new file mode 100644 index 0000000..8d9305e Binary files /dev/null and b/assets/brand/fanmu-logo.png differ diff --git a/biome.json b/biome.json index 526f064..24e6f4b 100644 --- a/biome.json +++ b/biome.json @@ -35,6 +35,8 @@ "includes": [ "**", "!**/storybook-static", + "!**/.vitepress/cache", + "!**/.vitepress/dist", "!**/dist", "!**/.turbo", "!**/node_modules", diff --git a/docs/superpowers/plans/2026-09-25-dandanplay-web.md b/docs/superpowers/plans/2026-09-25-dandanplay-web.md index 95ba0b7..0675583 100644 --- a/docs/superpowers/plans/2026-09-25-dandanplay-web.md +++ b/docs/superpowers/plans/2026-09-25-dandanplay-web.md @@ -2,7 +2,7 @@ > **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。 -**目标:** 在 `app-template` 产品化全栈上实现多用户 Web 媒体中心:WebDAV 挂载(含 OpenList)→ 扫描/Bangumi 刮削 → ArtPlayer 播放 + 弹幕(开放网络中继/本地 XML)→ 进度与 shadcn 风格 dashboard UI。 +**目标:** 在 本产品仓 产品化全栈上实现多用户 Web 媒体中心:WebDAV 挂载(含 OpenList)→ 扫描/Bangumi 刮削 → ArtPlayer 播放 + 弹幕(开放网络中继/本地 XML)→ 进度与 shadcn 风格 dashboard UI。 **架构:** 方案 A 紧凑垂直切片 `media`:types Zod → models 表 → dao → trpc service/router(挂载代理、stream、刮削、弹幕、库、进度)→ `@app/web` 薄 SSR + React islands。浏览器不持网盘密码;流经 `/api/stream` Range 代理。 @@ -1848,7 +1848,7 @@ export function AppShell(props: { <div className="flex min-h-screen bg-background text-foreground"> <aside className="hidden w-56 shrink-0 border-r border-border bg-muted/40 md:flex md:flex-col md:p-4"> <div className="mb-6 px-2 text-sm font-semibold tracking-wide"> - {props.labels["meta.brand"] ?? "弹弹 Web"} + {props.labels["meta.brand"] ?? "帆幕"} </div> <nav className="flex flex-col gap-1" aria-label="sidebar"> {NAV.map((item) => ( diff --git a/docs/superpowers/specs/2026-09-25-dandanplay-web-design.md b/docs/superpowers/specs/2026-09-25-dandanplay-web-design.md index 1b1a966..890e5cf 100644 --- a/docs/superpowers/specs/2026-09-25-dandanplay-web-design.md +++ b/docs/superpowers/specs/2026-09-25-dandanplay-web-design.md @@ -2,7 +2,7 @@ - **日期**:2026-09-25 - **状态**:已获设计批准,待实现计划(writing-plans) -- **仓库**:`app-template`(按**产品仓**维护,业务进入现有分层) +- **仓库**:本产品仓(按**产品仓**维护,业务进入现有分层) ## 1. 背景与调研结论 diff --git a/docs/superpowers/ui-review-2026-09-25/browse.png b/docs/superpowers/ui-review-2026-09-25/browse.png new file mode 100644 index 0000000..23d9826 Binary files /dev/null and b/docs/superpowers/ui-review-2026-09-25/browse.png differ diff --git a/docs/superpowers/ui-review-2026-09-25/danmaku.png b/docs/superpowers/ui-review-2026-09-25/danmaku.png new file mode 100644 index 0000000..b778e2c Binary files /dev/null and b/docs/superpowers/ui-review-2026-09-25/danmaku.png differ diff --git a/docs/superpowers/ui-review-2026-09-25/library.png b/docs/superpowers/ui-review-2026-09-25/library.png new file mode 100644 index 0000000..1711877 Binary files /dev/null and b/docs/superpowers/ui-review-2026-09-25/library.png differ diff --git a/docs/superpowers/ui-review-2026-09-25/mounts.png b/docs/superpowers/ui-review-2026-09-25/mounts.png new file mode 100644 index 0000000..ae2948d Binary files /dev/null and b/docs/superpowers/ui-review-2026-09-25/mounts.png differ diff --git a/docs/superpowers/ui-review-2026-09-25/v2-danmaku.png b/docs/superpowers/ui-review-2026-09-25/v2-danmaku.png new file mode 100644 index 0000000..87a74dd Binary files /dev/null and b/docs/superpowers/ui-review-2026-09-25/v2-danmaku.png differ diff --git a/docs/superpowers/ui-review-2026-09-25/v2-library.png b/docs/superpowers/ui-review-2026-09-25/v2-library.png new file mode 100644 index 0000000..9a78849 Binary files /dev/null and b/docs/superpowers/ui-review-2026-09-25/v2-library.png differ diff --git a/docs/superpowers/ui-review-2026-09-25/v3-library.png b/docs/superpowers/ui-review-2026-09-25/v3-library.png new file mode 100644 index 0000000..9a78849 Binary files /dev/null and b/docs/superpowers/ui-review-2026-09-25/v3-library.png differ diff --git a/docs/superpowers/ui-review-2026-09-25/watch-noid.png b/docs/superpowers/ui-review-2026-09-25/watch-noid.png new file mode 100644 index 0000000..0ce0d6e Binary files /dev/null and b/docs/superpowers/ui-review-2026-09-25/watch-noid.png differ diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts new file mode 100644 index 0000000..4e900a0 --- /dev/null +++ b/e2e/api.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from "@playwright/test"; + +test("GET /api/users 转发 tRPC user.list 返回用户列表", async ({ request }) => { + const res = await request.get("/api/users"); + expect(res.ok()).toBeTruthy(); + const body = await res.json(); + expect(Array.isArray(body?.result?.data?.users)).toBe(true); + // 安全:任何对外出口不得泄露 passwordHash + expect(JSON.stringify(body)).not.toContain("passwordHash"); +}); + +test("tRPC user.list 直接调用不返回 passwordHash", async ({ request }) => { + const input = encodeURIComponent(JSON.stringify({ limit: 20, offset: 0 })); + const res = await request.get(`/api/trpc/user.list?input=${input}`); + expect(res.ok()).toBeTruthy(); + expect(JSON.stringify(await res.json())).not.toContain("passwordHash"); +}); + +test("tRPC HTTP 适配可调用", async ({ request }) => { + const res = await request.get("/api/trpc/user.list"); + // 未登录/无输入时允许 200 或 4xx,但服务必须可达且返回 JSON + expect(res.headers()["content-type"]).toContain("application/json"); + const body = await res.text(); + expect(body.length).toBeGreaterThan(0); +}); diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts new file mode 100644 index 0000000..9492201 --- /dev/null +++ b/e2e/auth.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from "@playwright/test"; + +test("未登录访问 /zh-CN/dashboard 跳转登录并带 next", async ({ page }) => { + await page.goto("/zh-CN/dashboard"); + await expect(page).toHaveURL(/\/zh-CN\/auth\/login\?next=/); + await expect(page.getByRole("heading", { name: "登录" })).toBeVisible(); +}); + +test("未登录访问 /en/library 跳转英文登录页", async ({ page }) => { + await page.goto("/en/library"); + await expect(page).toHaveURL(/\/en\/auth\/login\?next=/); +}); + +test("登录页渲染用户名/密码表单", async ({ page }) => { + await page.goto("/zh-CN/auth/login"); + await expect(page.getByLabel("用户名")).toBeVisible(); + await expect(page.getByLabel("密码")).toBeVisible(); + await expect(page.getByRole("button", { name: "登录" })).toBeVisible(); +}); + +test("注册页可达", async ({ page }) => { + const res = await page.goto("/zh-CN/auth/register"); + expect(res?.status()).toBe(200); + await expect(page.getByLabel("邮箱")).toBeVisible(); +}); diff --git a/e2e/errors.spec.ts b/e2e/errors.spec.ts new file mode 100644 index 0000000..0266e8b --- /dev/null +++ b/e2e/errors.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from "@playwright/test"; + +test("未知路径返回 404 页面", async ({ page }) => { + const res = await page.goto("/zh-CN/definitely-not-a-page"); + expect(res?.status()).toBe(404); +}); + +test("裸非法路径视为非法 locale 前缀,302 到本地化首页", async ({ request }) => { + const res = await request.get("/no-such-page", { + headers: { "accept-language": "zh-CN" }, + maxRedirects: 0, + }); + expect(res.status()).toBe(302); + expect(res.headers()["location"]).toBe("/zh-CN"); +}); diff --git a/e2e/home.spec.ts b/e2e/home.spec.ts index 180be1e..33e5242 100644 --- a/e2e/home.spec.ts +++ b/e2e/home.spec.ts @@ -3,8 +3,8 @@ import { expect, test } from "@playwright/test"; test("中文首页渲染 hero 与 CTA", async ({ page }) => { await page.goto("/zh-CN"); const h1 = page.getByRole("heading", { level: 1 }); - await expect(h1).toContainText("本地片源"); - await expect(h1).toContainText("在线弹幕"); + await expect(h1).toContainText("私有影音库"); + await expect(h1).toContainText("弹幕观影"); await expect(page.getByRole("link", { name: "登录 / 注册" })).toBeVisible(); await expect(page.getByRole("link", { name: "打开媒体库" })).toBeVisible(); }); diff --git a/e2e/i18n.spec.ts b/e2e/i18n.spec.ts new file mode 100644 index 0000000..9e741f0 --- /dev/null +++ b/e2e/i18n.spec.ts @@ -0,0 +1,51 @@ +import { expect, test } from "@playwright/test"; + +test.describe("裸 / 重定向", () => { + test("默认 Accept-Language (en-US) 跳 /en", async ({ request }) => { + const res = await request.get("/", { + headers: { "accept-language": "en-US" }, + maxRedirects: 0, + }); + expect(res.status()).toBeGreaterThanOrEqual(300); + expect(res.status()).toBeLessThan(400); + expect(res.headers()["location"]).toContain("/en"); + }); + + test("Accept-Language zh-CN 跳 /zh-CN", async ({ request }) => { + const res = await request.get("/", { + headers: { "accept-language": "zh-CN,zh;q=0.9" }, + maxRedirects: 0, + }); + expect(res.status()).toBeGreaterThanOrEqual(300); + expect(res.status()).toBeLessThan(400); + expect(res.headers()["location"]).toContain("/zh-CN"); + }); + + test("cookie app-lang 优先于 Accept-Language", async ({ request }) => { + const res = await request.get("/", { + headers: { + cookie: "app-lang=zh-CN", + "accept-language": "en-US", + }, + maxRedirects: 0, + }); + expect(res.headers()["location"]).toContain("/zh-CN"); + }); +}); + +test.describe("locale 规范化", () => { + test("小写 /zh-cn 重定向到 /zh-CN", async ({ request }) => { + const res = await request.get("/zh-cn", { maxRedirects: 0 }); + expect(res.status()).toBeGreaterThanOrEqual(300); + expect(res.status()).toBeLessThan(400); + expect(res.headers()["location"]).toBe("/zh-CN"); + }); +}); + +test("header 语言切换链接跳到另一语言", async ({ page }) => { + await page.goto("/zh-CN"); + const switchLink = page.locator('[data-switch-locale="en"]'); + await expect(switchLink).toBeVisible(); + await switchLink.click(); + await expect(page).toHaveURL(/\/en$/); +}); diff --git a/package.json b/package.json index b5fd7b8..ea9bb44 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "app-template", + "name": "fanmu", "private": true, "workspaces": [ "packages/*", @@ -16,7 +16,11 @@ "db:generate": "turbo run db:generate", "db:push": "turbo run db:push", "db:studio": "turbo run db:studio", + "db:seed": "yarn workspace @app/db seed", + "docs:dev": "yarn workspace @app/docs dev", + "docs:build": "yarn workspace @app/docs build", "typecheck": "turbo run typecheck", + "test:e2e": "playwright test", "lint": "turbo run lint", "clean": "turbo run clean", "format": "turbo run format", @@ -33,6 +37,7 @@ "@commitlint/cli": "^21.2.3", "@commitlint/config-conventional": "^21.2.3", "@emnapi/runtime": "^1.11.3", + "@playwright/test": "^1.63.0", "@storybook/addon-a11y": "^9.1.10", "@storybook/addon-docs": "^9.1.10", "@storybook/react": "^9.1.10", diff --git a/packages/dao/AGENTS.md b/packages/dao/AGENTS.md index 0e6288c..78ee10e 100644 --- a/packages/dao/AGENTS.md +++ b/packages/dao/AGENTS.md @@ -2,16 +2,20 @@ ## 职责 -**数据访问层(DAO)**:封装 Drizzle 查询(`userDao` / `sessionDao` / `passwordResetDao`)。service 只调本包,不直接 `db.select`。不含业务规则、HTTP/tRPC。 +**数据访问层(DAO)**:封装 Drizzle 查询。service 只调本包,不直接 `db.select`。不含业务规则、HTTP/tRPC。 ## 结构 ``` src/ - index.ts # 出口:daos + 行类型 - users.ts # userDao - sessions.ts # sessionDao - password-resets.ts # passwordResetDao + index.ts # 出口:daos + 行类型 + users.ts # userDao + sessions.ts # sessionDao + password-resets.ts # passwordResetDao + mounts.ts # mountDao + media-items.ts # mediaItemDao + playback-progress.ts # playbackProgressDao + danmaku-cache.ts # danmakuCacheDao ``` ## 依赖 @@ -21,15 +25,17 @@ src/ ## 约定 -- 一表一个 dao 对象;方法名 `list` / `getById` / `create` / `update` / `delete`。 +- 一表一个 dao 对象;方法名 `list` / `getById` / `create` / `update` / `delete`(媒体域可加 `getByPath`、`upsert` 等,但保持动词清晰)。 - `exactOptionalPropertyTypes`:update 入参用 `Partial<Omit<Row, "id" | "createdAt" | "updatedAt">>`。 - `noUncheckedIndexedAccess`:`returning()[0]` 判空后抛错或 `?? null`。 - 不读 env、不建 client;环境变量校验在 `@app/db`。 +- 分页/过滤等查询参数类型一并从本包导出(如 `ListUsersParams`)。 ## 修改指南 - 加查询:新建或扩展对应 `*.ts`,在 `index.ts` 导出。 - 新表:先改 `@app/models` → `yarn db:generate`/`db:push` → 本包加 dao。 +- 媒体域查询注意 `mediaItems` 唯一键含 `mountId`,按路径查找时必须带挂载维度。 ## 命令 diff --git a/packages/dao/src/media-items.ts b/packages/dao/src/media-items.ts index 4afbf21..a40eee2 100644 --- a/packages/dao/src/media-items.ts +++ b/packages/dao/src/media-items.ts @@ -1,5 +1,5 @@ -import { and, desc, eq, like, or, sql } from "drizzle-orm"; -import { mediaItems, type MediaItem } from "@app/models"; +import { and, asc, desc, eq, inArray, isNotNull, like, or, sql } from "drizzle-orm"; +import { mediaItems, mounts, type MediaItem } from "@app/models"; import { db } from "@app/db"; export type MediaItemRow = MediaItem; @@ -10,11 +10,13 @@ export const mediaItemDao = { params: { limit: number; offset: number; + mountId?: string | undefined; scrapeStatus?: string | undefined; q?: string | undefined; }, ): Promise<{ rows: MediaItemRow[]; total: number }> { const conds = [eq(mediaItems.userId, userId)]; + if (params.mountId) conds.push(eq(mediaItems.mountId, params.mountId)); if (params.scrapeStatus) conds.push(eq(mediaItems.scrapeStatus, params.scrapeStatus)); if (params.q) { const likeQ = `%${params.q}%`; @@ -35,6 +37,143 @@ export const mediaItemDao = { .where(where); return { rows, total: Number(countRow?.["count"] ?? 0) }; }, + /** + * 海报墙分组分页:指定挂载下已刮削条目按 bangumiId 分组。 + * q 命中任一集即收入该组,但 episodeCount 始终为该组全量集数(与列表筛选「已刮削」对得上)。 + */ + async listScrapedGroups( + userId: string, + params: { + mountId: string; + q?: string | undefined; + limit: number; + offset: number; + }, + ): Promise<{ + rows: { + bangumiId: number; + title: string; + posterUrl: string | null; + episodeCount: number; + updatedAt: Date; + }[]; + total: number; + }> { + const baseConds = [ + eq(mediaItems.userId, userId), + eq(mediaItems.mountId, params.mountId), + eq(mediaItems.scrapeStatus, "ok"), + isNotNull(mediaItems.bangumiId), + ]; + const fullWhere = and(...baseConds); + + // 先找出 q 命中的作品 id;命中集合决定「有哪些组」,不缩小组内集数 + let matchIds: number[] | null = null; + if (params.q) { + const likeQ = `%${params.q}%`; + const qCond = or(like(mediaItems.title, likeQ), like(mediaItems.rawName, likeQ)); + const matched = await db + .select({ bangumiId: mediaItems.bangumiId }) + .from(mediaItems) + .where(and(...baseConds, qCond)) + .groupBy(mediaItems.bangumiId); + matchIds = matched.map((r) => r.bangumiId).filter((x): x is number => x != null); + if (matchIds.length === 0) return { rows: [], total: 0 }; + } + + const scopeWhere = + matchIds == null ? fullWhere : and(fullWhere, inArray(mediaItems.bangumiId, matchIds)); + + // rn=1 即每组 updated_at 最新的一行,其 title/posterUrl 就代表该组 + const ranked = db + .select({ + bangumiId: mediaItems.bangumiId, + title: mediaItems.title, + posterUrl: mediaItems.posterUrl, + updatedAt: mediaItems.updatedAt, + rn: sql<number>`row_number() over (partition by ${mediaItems.bangumiId} order by ${mediaItems.updatedAt} desc, ${mediaItems.id} desc)`.as( + "rn", + ), + episodeCount: sql<number>`count(*) over (partition by ${mediaItems.bangumiId})`.as( + "episode_count", + ), + }) + .from(mediaItems) + .where(scopeWhere) + .as("ranked"); + const pageRows = await db + .select({ + bangumiId: ranked.bangumiId, + title: ranked.title, + posterUrl: ranked.posterUrl, + episodeCount: ranked.episodeCount, + updatedAt: ranked.updatedAt, + }) + .from(ranked) + .where(eq(ranked.rn, 1)) + .orderBy(desc(ranked.updatedAt), desc(ranked.bangumiId)) + .limit(params.limit) + .offset(params.offset); + const total = + matchIds != null + ? matchIds.length + : await this.countScrapedSeries(userId, params.mountId); + const rows: { + bangumiId: number; + title: string; + posterUrl: string | null; + episodeCount: number; + updatedAt: Date; + }[] = []; + for (const row of pageRows) { + const bid = row.bangumiId; + if (bid == null) continue; + rows.push({ + bangumiId: bid, + title: row.title, + posterUrl: row.posterUrl, + episodeCount: Number(row.episodeCount), + updatedAt: row.updatedAt, + }); + } + return { rows, total }; + }, + async countScrapedSeries(userId: string, mountId: string): Promise<number> { + const [countRow] = await db + .select({ count: sql<number>`count(distinct ${mediaItems.bangumiId})` }) + .from(mediaItems) + .where( + and( + eq(mediaItems.userId, userId), + eq(mediaItems.mountId, mountId), + eq(mediaItems.scrapeStatus, "ok"), + isNotNull(mediaItems.bangumiId), + ), + ); + return Number(countRow?.["count"] ?? 0); + }, + /** 路径前缀下的条目(文件夹级批量刮削/指定用);精确目录边界,避免 /Foo 误吞 /FooBar */ + async listByPathPrefix(userId: string, mountId: string, path: string): Promise<MediaItemRow[]> { + const normPrefix = path.replace(/\/+$/, "") || "/"; + const base = [eq(mediaItems.userId, userId), eq(mediaItems.mountId, mountId)]; + if (normPrefix === "/") { + return db + .select() + .from(mediaItems) + .where(and(...base)) + .orderBy(asc(mediaItems.path)); + } + return db + .select() + .from(mediaItems) + .where( + and( + ...base, + or(eq(mediaItems.path, normPrefix), like(mediaItems.path, `${normPrefix}/%`)), + ), + ) + .orderBy(asc(mediaItems.path)); + }, async getByIdForUser(id: string, userId: string): Promise<MediaItemRow | null> { const [row] = await db .select() @@ -43,6 +182,20 @@ export const mediaItemDao = { .limit(1); return row ?? null; }, + /** 同作品(bangumiId)剧集,按集数升序;mountId 可选,库内详情只看该挂载 */ + async listByBangumiId( + userId: string, + bangumiId: number, + mountId?: string | undefined, + ): Promise<MediaItemRow[]> { + const conds = [eq(mediaItems.userId, userId), eq(mediaItems.bangumiId, bangumiId)]; + if (mountId) conds.push(eq(mediaItems.mountId, mountId)); + return db + .select() + .from(mediaItems) + .where(and(...conds)) + .orderBy(asc(mediaItems.epNumber), asc(mediaItems.rawName)); + }, async getByUserPath( userId: string, mountId: string, @@ -107,6 +260,8 @@ export const mediaItemDao = { userId: string, data: { bangumiId?: number | null; + dandanplayEpisodeId?: number | null; + matchedHash?: string | null; epNumber?: number | null; scrapeStatus: string; scrapedAt?: Date | null; @@ -121,4 +276,37 @@ export const mediaItemDao = { .returning(); return row ?? null; }, + /** 弹幕识别身份落库(owner 校验):episodeId + 识别用文件指纹,后续播放免重 match */ + async setDanmakuMatch( + id: string, + userId: string, + data: { episodeId: number; matchedHash?: string | null }, + ): Promise<MediaItemRow | null> { + const [row] = await db + .update(mediaItems) + .set({ + dandanplayEpisodeId: data.episodeId, + matchedHash: data.matchedHash ?? null, + updatedAt: new Date(), + }) + .where(and(eq(mediaItems.id, id), eq(mediaItems.userId, userId))) + .returning(); + return row ?? null; + }, + /** 删除挂载下的全部条目(挂载删除级联),返回被删条目 id 供清理进度 */ + async deleteByMount(mountId: string, userId: string): Promise<string[]> { + const rows = await db + .delete(mediaItems) + .where(and(eq(mediaItems.mountId, mountId), eq(mediaItems.userId, userId))) + .returning({ id: mediaItems.id }); + return rows.map((r) => r.id); + }, + /** 清理挂载已不存在的孤儿条目(历史脏数据兜底),返回删除数量 */ + async cleanupOrphans(): Promise<number> { + const rows = await db + .delete(mediaItems) + .where(sql`${mediaItems.mountId} not in (select id from ${mounts})`) + .returning({ id: mediaItems.id }); + return rows.length; + }, }; diff --git a/packages/dao/src/playback-progress.ts b/packages/dao/src/playback-progress.ts index 32897fe..1e5befe 100644 --- a/packages/dao/src/playback-progress.ts +++ b/packages/dao/src/playback-progress.ts @@ -1,7 +1,9 @@ -import { and, eq } from "drizzle-orm"; -import { playbackProgress, type PlaybackProgress } from "@app/models"; +import { and, desc, eq, gt, inArray, lt, or, sql } from "drizzle-orm"; +import { mediaItems, playbackProgress, type PlaybackProgress } from "@app/models"; import { db } from "@app/db"; +export type PlaybackProgressRow = PlaybackProgress; + export const playbackProgressDao = { async get(userId: string, mediaItemId: string): Promise<PlaybackProgress | null> { const [row] = await db @@ -16,6 +18,24 @@ export const playbackProgressDao = { .limit(1); return row ?? null; }, + /** 未看完的最近进度(positionMs>0 且未接近结尾),按更新时间倒序 */ + async listUnfinished(userId: string, limit: number): Promise<PlaybackProgress[]> { + return db + .select() + .from(playbackProgress) + .where( + and( + eq(playbackProgress.userId, userId), + gt(playbackProgress.positionMs, 0), + or( + eq(playbackProgress.durationMs, 0), + lt(playbackProgress.positionMs, playbackProgress.durationMs), + ), + ), + ) + .orderBy(desc(playbackProgress.updatedAt)) + .limit(limit); + }, async upsert( userId: string, mediaItemId: string, @@ -40,4 +60,17 @@ export const playbackProgressDao = { if (!row) throw new Error("Failed to insert progress"); return row; }, + /** 删除指定媒体条目的进度(挂载/条目删除级联) */ + async deleteByMediaItems(mediaItemIds: string[]): Promise<void> { + if (mediaItemIds.length === 0) return; + await db + .delete(playbackProgress) + .where(inArray(playbackProgress.mediaItemId, mediaItemIds)); + }, + /** 清理媒体条目已不存在的孤儿进度(历史脏数据兜底) */ + async cleanupOrphans(): Promise<void> { + await db + .delete(playbackProgress) + .where(sql`${playbackProgress.mediaItemId} not in (select id from ${mediaItems})`); + }, }; diff --git a/packages/dao/src/users.ts b/packages/dao/src/users.ts index 00da792..bb19fcb 100644 --- a/packages/dao/src/users.ts +++ b/packages/dao/src/users.ts @@ -26,6 +26,15 @@ export const userDao = { return row ?? null; }, + async getByUsername(username: string): Promise<UserRow | null> { + const [row] = await db + .select() + .from(users) + .where(eq(users.username, username.toLowerCase())) + .limit(1); + return row ?? null; + }, + async getByEmail(email: string): Promise<UserRow | null> { const [row] = await db .select() @@ -36,9 +45,17 @@ export const userDao = { }, async create( - data: Omit<UserRow, "id" | "createdAt" | "updatedAt"> & { id?: string }, + data: Omit<UserRow, "id" | "createdAt" | "updatedAt" | "username" | "email"> & { + id?: string; + username: string; + email?: string | null; + }, ): Promise<UserRow> { - const payload = { ...data, email: data.email.toLowerCase() }; + const payload = { + ...data, + username: data.username.toLowerCase(), + email: data.email ? data.email.toLowerCase() : null, + }; const rows = await db.insert(users).values(payload).returning(); const row = rows[0]; if (!row) throw new Error("Failed to create user"); @@ -50,7 +67,10 @@ export const userDao = { data: Partial<Omit<UserRow, "id" | "createdAt" | "updatedAt">>, ): Promise<UserRow | null> { const payload: Partial<Omit<UserRow, "id" | "createdAt" | "updatedAt">> = { ...data }; - if (payload.email !== undefined) payload.email = payload.email.toLowerCase(); + if (payload.username !== undefined) payload.username = payload.username.toLowerCase(); + if (payload.email !== undefined) { + payload.email = payload.email ? payload.email.toLowerCase() : null; + } const [row] = await db .update(users) .set({ ...payload, updatedAt: new Date() }) diff --git a/packages/db/AGENTS.md b/packages/db/AGENTS.md index 5f4e61f..cd65338 100644 --- a/packages/db/AGENTS.md +++ b/packages/db/AGENTS.md @@ -2,7 +2,7 @@ ## 职责 -创建并导出 **Drizzle + libSQL 客户端单例**,以及 drizzle-kit 配置。查询在 `@app/dao`,业务在 `@app/trpc` service。 +创建并导出 **Drizzle + libSQL 客户端单例**,以及 drizzle-kit 配置与种子脚本。查询在 `@app/dao`,业务在 `@app/trpc` service。 ## 结构 @@ -10,6 +10,8 @@ src/ client.ts # export const db = drizzle(client, { schema }) index.ts # 对外 re-export +scripts/ + seed.ts # 幂等初始化默认管理员(yarn db:seed) drizzle.config.ts # dialect: turso;schema 指向 ../models/dist/index.js ``` @@ -18,8 +20,14 @@ drizzle.config.ts # dialect: turso;schema 指向 ../models/dist/index.js - 读环境变量:`TURSO_DATABASE_URL`(必填,缺失则启动抛错)、`TURSO_AUTH_TOKEN`(可选)。 - 本地开发常用 `TURSO_DATABASE_URL=file:local.db`。 - `schema` 来自 `@app/models`——**模型定义请去 models 改**,本包只聚合导入。 -- **不写查询**;SQL/Drizzle query 在 `@app/dao`。 -- `drizzle.config.ts` 会手动解析根目录 `.env`(monorepo 下 drizzle-kit 不自动加载)。改 env 路径时两处保持一致。 +- **不写业务查询**;SQL/Drizzle query 在 `@app/dao`。 +- `drizzle.config.ts` 与 `scripts/seed.ts` 都会手动解析根目录 `.env`(monorepo 下 drizzle-kit / tsx 不自动加载)。改 env 路径时保持一致。 + +## seed 脚本 + +- `yarn db:seed`:创建默认管理员(用户名 + 密码),已存在同名用户则跳过(幂等)。 +- 密码哈希格式与 `@app/trpc` 的 `password.ts` 一致(scrypt `salt:hash` hex)——改哈希算法时两处同步。 +- 脚本可直接依赖 `@app/models` 与 `@libsql/client`,但不要在 `src/` 里塞业务逻辑。 ## 修改指南 @@ -34,5 +42,6 @@ yarn workspace @app/db typecheck yarn workspace @app/db build yarn db:generate yarn db:push +yarn db:seed yarn db:studio ``` diff --git a/packages/db/package.json b/packages/db/package.json index e3e5bfc..b1a58b1 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -20,7 +20,8 @@ "lint": "biome check .", "db:generate": "drizzle-kit generate", "db:push": "drizzle-kit push", - "db:studio": "drizzle-kit studio" + "db:studio": "drizzle-kit studio", + "seed": "tsx scripts/seed.ts" }, "dependencies": { "@app/models": "workspace:*", @@ -30,6 +31,7 @@ "devDependencies": { "@types/node": "^22.15.29", "drizzle-kit": "^0.30.2", + "tsx": "^4.19.4", "typescript": "^7.0.2" } } diff --git a/packages/db/scripts/seed.ts b/packages/db/scripts/seed.ts new file mode 100644 index 0000000..1f4022a --- /dev/null +++ b/packages/db/scripts/seed.ts @@ -0,0 +1,77 @@ +/** + * Seed:初始化默认管理员(用户名 + 密码登录)。 + * 用法:`yarn db:seed`(幂等:已存在同名用户则跳过)。 + * 密码哈希格式与 `@app/trpc` 的 `password.ts` 一致(scrypt `salt:hash` hex)。 + */ +import { randomBytes, scryptSync } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { eq } from "drizzle-orm"; +import { createClient } from "@libsql/client"; +import { drizzle } from "drizzle-orm/libsql"; +import * as schema from "@app/models"; + +// monorepo 根 .env(tsx/node 不会自动加载) +try { + const rootEnv = resolve(dirname(fileURLToPath(import.meta.url)), "../../../.env"); + const env = readFileSync(rootEnv, "utf-8"); + for (const line of env.split("\n")) { + const [key, ...vals] = line.split("="); + if (key && !key.startsWith("#")) { + process.env[key.trim()] ??= vals.join("=").trim(); + } + } +} catch {} + +const url = process.env["TURSO_DATABASE_URL"]; +if (!url) throw new Error("TURSO_DATABASE_URL is required"); + +const clientConfig: { url: string; authToken?: string } = { url }; +const token = process.env["TURSO_AUTH_TOKEN"]; +if (token) clientConfig.authToken = token; + +const client = createClient(clientConfig); +const db = drizzle(client, { schema }); + +function hashPassword(password: string): string { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(password, salt, 64).toString("hex"); + return `${salt}:${hash}`; +} + +const ADMIN_USERNAME = (process.env["SEED_ADMIN_USERNAME"] ?? "admin").toLowerCase(); +const ADMIN_PASSWORD = process.env["SEED_ADMIN_PASSWORD"] ?? "admin123"; +const ADMIN_NAME = process.env["SEED_ADMIN_NAME"] ?? "Admin"; +const ADMIN_EMAIL = process.env["SEED_ADMIN_EMAIL"] ?? "admin@example.com"; + +async function seed(): Promise<void> { + const { users } = schema; + const existing = await db + .select({ id: users.id, username: users.username }) + .from(users) + .where(eq(users.username, ADMIN_USERNAME)) + .limit(1); + + if (existing.length > 0) { + console.log(`[seed] admin 已存在(username=${ADMIN_USERNAME}),跳过创建`); + return; + } + + await db.insert(users).values({ + username: ADMIN_USERNAME, + name: ADMIN_NAME, + email: ADMIN_EMAIL.toLowerCase(), + passwordHash: hashPassword(ADMIN_PASSWORD), + }); + + console.log(`[seed] 已创建管理员用户 username=${ADMIN_USERNAME} password=${ADMIN_PASSWORD}`); + console.log("[seed] 生产环境请通过 SEED_ADMIN_PASSWORD 覆盖默认密码,并尽快登录后修改"); +} + +seed() + .then(() => process.exit(0)) + .catch((err) => { + console.error("[seed] 失败:", err); + process.exit(1); + }); diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json index 7c08a69..f5ccb52 100644 --- a/packages/db/tsconfig.json +++ b/packages/db/tsconfig.json @@ -7,5 +7,5 @@ "@app/models": ["../models/src/index.ts"] } }, - "include": ["src"] + "include": ["src", "scripts"] } diff --git a/packages/design-tokens/src/tokens.css b/packages/design-tokens/src/tokens.css index d79f367..bff71b9 100644 --- a/packages/design-tokens/src/tokens.css +++ b/packages/design-tokens/src/tokens.css @@ -6,6 +6,27 @@ --radius-md: 6px; --radius-lg: 8px; --radius-xl: 12px; + + /* shadcn 工具类映射(bg-card / bg-popover / text-muted-foreground …) */ + --color-background: var(--paper); + --color-foreground: var(--ink); + --color-card: var(--panel); + --color-card-foreground: var(--ink); + --color-popover: var(--panel); + --color-popover-foreground: var(--ink); + --color-primary: var(--edge); + --color-primary-foreground: #101110; + --color-muted: var(--sink); + --color-muted-foreground: var(--ink-2); + --color-border: var(--line); + --color-input: var(--line); + --color-ring: var(--edge); + --color-accent: var(--sink); + --color-accent-foreground: var(--ink); + --color-secondary: var(--line-strong); + --color-secondary-foreground: var(--ink); + --color-destructive: var(--err); + --color-destructive-foreground: #f2f2f2; } /* 终末地设计语义 @@ -28,11 +49,11 @@ --ink-2: #a3a6a1; --ink-3: #7a7e78; - --edge: #fffa00; - --edge-rgb: 255, 250, 0; - --edge-deep: #e6e100; - --edge-on-paper: #fffa00; - --edge-as-text: #fffa00; + --edge: #ffb300; + --edge-rgb: 255, 179, 0; + --edge-deep: #e09a00; + --edge-on-paper: #ffb300; + --edge-as-text: #ffb300; --sig-green: #00ffa2; --sig-magenta: #ff1aac; @@ -45,17 +66,20 @@ --warn: #ffb700; --hazard: repeating-linear-gradient(-45deg, #191919 0 8px, #0e0f0e 8px 16px); - --hazard-edge: repeating-linear-gradient(-45deg, #fffa00 0 8px, #0e0f0e 8px 16px); + --hazard-edge: repeating-linear-gradient(-45deg, #ffb300 0 8px, #0e0f0e 8px 16px); --color-background: var(--paper); --color-foreground: var(--ink); --color-card: var(--panel); --color-card-foreground: var(--ink); + --color-popover: var(--panel); + --color-popover-foreground: var(--ink); --color-primary: var(--edge); --color-primary-foreground: #101110; --color-muted: var(--sink); --color-muted-foreground: var(--ink-2); --color-border: var(--line); + --color-input: var(--line); --color-ring: var(--edge); --color-accent: var(--sink); --color-accent-foreground: var(--ink); @@ -78,11 +102,11 @@ --ink-2: #4a4d48; --ink-3: #5c5f5a; - --edge: #fffa00; - --edge-rgb: 255, 250, 0; - --edge-deep: #e6e100; - --edge-on-paper: #d4cf00; - --edge-as-text: #6b6500; + --edge: #ffb300; + --edge-rgb: 255, 179, 0; + --edge-deep: #e09a00; + --edge-on-paper: #b88600; + --edge-as-text: #7a5c00; --sig-green: #00c982; --sig-magenta: #e01090; @@ -95,17 +119,20 @@ --warn: #d9822b; --hazard: repeating-linear-gradient(-45deg, #e5e5e5 0 8px, #f2f2f2 8px 16px); - --hazard-edge: repeating-linear-gradient(-45deg, #fffa00 0 8px, #191919 8px 16px); + --hazard-edge: repeating-linear-gradient(-45deg, #ffb300 0 8px, #191919 8px 16px); --color-background: var(--paper); --color-foreground: var(--ink); --color-card: var(--panel); --color-card-foreground: var(--ink); + --color-popover: var(--panel); + --color-popover-foreground: var(--ink); --color-primary: var(--edge); --color-primary-foreground: #101110; --color-muted: var(--sink); --color-muted-foreground: var(--ink-2); --color-border: var(--line); + --color-input: var(--line); --color-ring: var(--edge); --color-accent: var(--sink); --color-accent-foreground: var(--ink); diff --git a/packages/i18n/AGENTS.md b/packages/i18n/AGENTS.md index b86917c..ecbb965 100644 --- a/packages/i18n/AGENTS.md +++ b/packages/i18n/AGENTS.md @@ -19,6 +19,8 @@ src/ index.ts ``` +key 按域分组(`<domain>.<name>`):`nav` / `home` / `dashboard` / `auth` / `library` / `browse` / `watch` / `mounts` / `danmaku` / `error` / `meta` / `locale`。 + ## 约定 - 中文 `/zh-CN`、英文 `/en` 前缀;裸 `/` 仅由 middleware 检测后 302,不挂页面。 diff --git a/packages/i18n/src/messages/en.ts b/packages/i18n/src/messages/en.ts index 48d928c..c415a6f 100644 --- a/packages/i18n/src/messages/en.ts +++ b/packages/i18n/src/messages/en.ts @@ -2,8 +2,8 @@ import type { MessageKey } from "./zh-CN.js"; /** 与 zh-CN 同构;类型强制 key 对齐。 */ export const en: Record<MessageKey, string> = { - "meta.brand": "DanDan Web", - "meta.product": "DanDan Web", + "meta.brand": "Fanmu", + "meta.product": "Fanmu", "meta.productTagline": "Local files · online danmaku", "meta.tagline": "Local video · online danmaku", @@ -17,7 +17,7 @@ export const en: Record<MessageKey, string> = { "nav.mounts": "Mounts", "nav.danmaku": "Danmaku settings", - "home.title": "DanDan Web", + "home.title": "Fanmu", "home.subtitle": "Mount WebDAV / OpenList libraries, scan into your media library, play with ArtPlayer and online danmaku.", "home.heroLine1": "Local files", @@ -30,7 +30,7 @@ export const en: Record<MessageKey, string> = { "home.layerTitle": "Get started", "home.layerBody": "Register → add a WebDAV mount → scan in Browse → play from the Library with danmaku.", - "home.footer": "DANDAN WEB · SSR", + "home.footer": "Private library · Danmaku", "dashboard.title": "Console", "dashboard.heading": "Media hub", @@ -56,6 +56,34 @@ export const en: Record<MessageKey, string> = { "library.searchWork": "Search by title", "library.bind": "Bind", "library.cancel": "Cancel", + "library.viewWall": "Poster wall", + "library.viewList": "List", + "library.episodeCount": "{count} eps", + "library.groupsEmpty": + "No scraped titles yet. Scan a mount or match manually to see them here.", + "library.episodes": "Episodes", + "library.expand": "Show episodes", + "library.collapse": "Collapse", + "library.epLabel": "Episode", + "library.epPlaceholder": "Optional, e.g. 12", + "library.reScrape": "Re-scrape", + "library.manualTitle": "Manual match", + "library.candidates": "Candidates (click to bind)", + "browse.batchScrape": "Batch scrape folder", + "browse.batchBind": "Bind folder as title", + "browse.batchDone": "{message}", + "browse.batchError": "Batch action failed", + "browse.folderActions": "Folder actions", + "browse.bindFolderTitle": "Bind folder to a title", + "browse.bindFolderHint": + "Search and pick a title; all videos under this folder will bind to it, episodes parsed from filenames.", + "library.backToList": "Back to library", + "library.continueTitle": "Continue watching", + "library.matched": "Matched", + "library.autoScanning": "Auto-scanning mounted directories into the library…", + "library.noResults": "No results matching “{q}”", + "library.filterEmpty": "Nothing under the current filter — try another filter", + "library.clearSearch": "Clear search", "browse.title": "File browser", "browse.play": "Play", @@ -71,6 +99,7 @@ export const en: Record<MessageKey, string> = { "browse.playError": "Failed to add to library", "browse.scanDone": "Scan complete: {count} files", "browse.scanError": "Scan failed. Check the mount configuration.", + "browse.goLibrary": "Go to library", "mounts.title": "Mounts", "mounts.add": "Add WebDAV", @@ -83,8 +112,34 @@ export const en: Record<MessageKey, string> = { "mounts.passwordPlaceholder": "Password / token", "mounts.rootPathPlaceholder": "Root path /", "mounts.save": "Save", + "mounts.saveEdit": "Save changes", + "mounts.saved": "Mount saved", + "mounts.updated": "Mount updated", + "mounts.saveError": "Save failed — try again", "mounts.delete": "Delete", + "mounts.deleted": "Mount deleted", + "mounts.deleteError": "Delete failed", + "mounts.edit": "Edit", + "mounts.editing": "Editing", + "mounts.editHeading": "Edit mount", + "mounts.cancelEdit": "Cancel edit", + "mounts.passwordKeepHint": "Password / token (leave blank to keep)", + "mounts.fieldName": "Name", + "mounts.fieldUrl": "WebDAV URL", + "mounts.fieldUser": "Username", + "mounts.fieldPass": "Password / token", + "mounts.fieldRoot": "Root path", + "mounts.listTitle": "Mounted", + "mounts.disabled": "Disabled", "mounts.empty": "No mounts yet", + "mounts.browseThis": "Browse files", + "mounts.afterCreateHint": "Mount saved — browse its videos now.", + "mounts.toBrowse": "Browse", + "mounts.errNameRequired": "Enter a mount name", + "mounts.errUrlInvalid": "Enter a valid http(s) URL, e.g. https://host/dav", + "mounts.confirmDelete": "Confirm delete?", + "mounts.deleteHint": "Media items scanned from this mount will also be removed", + "mounts.deletedN": "Mount deleted ({n} media items removed)", "watch.title": "Watch", "watch.loading": "Loading…", @@ -103,6 +158,23 @@ export const en: Record<MessageKey, string> = { "watch.loadErrorHint": "Could not load this media. Check the link or try again later.", "watch.notFound": "Media not found", "watch.notFoundHint": "This media may have been removed, or you do not have access.", + "watch.episodes": "Episodes", + "watch.prevEpisode": "Prev", + "watch.nextEpisode": "Next", + "watch.currentEpisode": "Now playing", + "watch.playNextCta": "Play next", + "watch.backToSeries": "Back to series", + "watch.danmakuSettings": "Danmaku settings", + "watch.episodeN": "EP {n}", + "watch.importOk": "Local danmaku imported", + "watch.importFail": "Danmaku import failed", + "watch.playFailed": "Playback source failed", + "watch.playFailedHint": + "Cannot read the video stream — the mount may be removed or the source unavailable.", + "watch.replay": "Replay", + "watch.playbackFinished": "Episode finished", + "watch.danmakuDisabledHint": + "Danmaku is off — enable it via the button above or in danmaku settings", "danmaku.title": "Danmaku settings", "danmaku.opacity": "Opacity", @@ -161,7 +233,10 @@ export const en: Record<MessageKey, string> = { "auth.msg.logoutOk": "Signed out", "auth.msg.passwordOk": "Password updated", "auth.msg.resetOk": "Password reset — please sign in", - "auth.msg.resetRequested": "If the email exists, the reset request was accepted", + "auth.msg.resetRequested": + "If the email exists, the reset request was accepted; a reset link will be sent to the mailbox (if no mail service is configured, contact the admin or check server logs)", + "auth.passwordHint": "At least 8 characters", + "auth.forgot.emailLabel": "Registered email", "auth.err.generic": "Something went wrong — try again", "locale.switch": "Language", diff --git a/packages/i18n/src/messages/zh-CN.ts b/packages/i18n/src/messages/zh-CN.ts index 353c79e..09a2db0 100644 --- a/packages/i18n/src/messages/zh-CN.ts +++ b/packages/i18n/src/messages/zh-CN.ts @@ -3,10 +3,10 @@ * 展示名与 description 的插值用 {name}。 */ export const zhCN = { - "meta.brand": "弹弹 Web", - "meta.product": "弹弹 Web", - "meta.productTagline": "本地片源 · 在线弹幕", - "meta.tagline": "本地视频 · 在线弹幕", + "meta.brand": "帆幕", + "meta.product": "帆幕", + "meta.productTagline": "私有影音库 · 弹幕观影 · 网盘挂载", + "meta.tagline": "私有影音库 · 弹幕观影 · 网盘挂载", "nav.home": "首页", "nav.dashboard": "控制台", @@ -18,10 +18,10 @@ export const zhCN = { "nav.mounts": "挂载管理", "nav.danmaku": "弹幕设置", - "home.title": "弹弹 Web", + "home.title": "帆幕", "home.subtitle": "挂载 WebDAV / OpenList 片源,扫描入库,用 ArtPlayer 播放并加载在线弹幕。", - "home.heroLine1": "本地片源", - "home.heroLine2": "在线弹幕", + "home.heroLine1": "私有影音库", + "home.heroLine2": "弹幕观影", "home.ctaDashboard": "打开媒体库", "home.ctaLogin": "登录 / 注册", "home.stackTitle": "能力", @@ -29,7 +29,7 @@ export const zhCN = { "WebDAV 挂载 · Bangumi 刮削 · ArtPlayer + 弹幕 · 多用户进度 · shadcn 风媒体库。", "home.layerTitle": "快速开始", "home.layerBody": "注册登录 → 挂载管理添加 WebDAV → 文件浏览扫描 → 媒体库播放并看弹幕。", - "home.footer": "DANDAN WEB · SSR", + "home.footer": "私有影音库 · 弹幕观影", "dashboard.title": "控制台", "dashboard.heading": "媒体中心", @@ -54,6 +54,33 @@ export const zhCN = { "library.searchWork": "搜索作品名", "library.bind": "绑定", "library.cancel": "取消", + "library.viewWall": "海报墙", + "library.viewList": "列表", + "library.episodeCount": "{count} 集", + "library.groupsEmpty": "暂无已刮削作品,扫描挂载或手动匹配后会出现在这里。", + "library.episodes": "剧集", + "library.expand": "展开剧集", + "library.collapse": "收起", + "library.epLabel": "集数", + "library.epPlaceholder": "可选,如 12", + "library.reScrape": "重新刮削", + "library.manualTitle": "手动指定", + "library.candidates": "候选作品(点选绑定)", + "browse.batchScrape": "批量刮削此夹", + "browse.batchBind": "指定此夹为作品", + "browse.batchDone": "{message}", + "browse.batchError": "批量操作失败", + "browse.folderActions": "文件夹操作", + "browse.bindFolderTitle": "整夹指定作品", + "browse.bindFolderHint": + "搜索并选定作品后,该文件夹下所有视频将绑定为同一作品,集数按文件名自动解析。", + "library.backToList": "返回媒体库", + "library.continueTitle": "继续观看", + "library.matched": "已匹配成功", + "library.autoScanning": "正在自动扫描已挂载目录并入库…", + "library.noResults": "没有匹配「{q}」的结果", + "library.filterEmpty": "当前筛选下没有条目,试试切换筛选条件", + "library.clearSearch": "清除搜索", "browse.title": "文件浏览", "browse.play": "播放", @@ -69,6 +96,7 @@ export const zhCN = { "browse.playError": "入库失败,请重试", "browse.scanDone": "扫描完成:{count} 个文件", "browse.scanError": "扫描失败,请检查挂载配置", + "browse.goLibrary": "去媒体库整理", "mounts.title": "挂载管理", "mounts.add": "添加 WebDAV", @@ -81,8 +109,34 @@ export const zhCN = { "mounts.passwordPlaceholder": "密码 / 令牌", "mounts.rootPathPlaceholder": "根路径 /", "mounts.save": "保存", + "mounts.saveEdit": "保存修改", + "mounts.saved": "挂载已保存", + "mounts.updated": "挂载已更新", + "mounts.saveError": "保存失败,请重试", "mounts.delete": "删除", + "mounts.deleted": "挂载已删除", + "mounts.deleteError": "删除失败", + "mounts.edit": "编辑", + "mounts.editing": "编辑中", + "mounts.editHeading": "编辑挂载", + "mounts.cancelEdit": "取消编辑", + "mounts.passwordKeepHint": "密码 / 令牌(留空保持不变)", + "mounts.fieldName": "名称", + "mounts.fieldUrl": "WebDAV 地址", + "mounts.fieldUser": "用户名", + "mounts.fieldPass": "密码 / 令牌", + "mounts.fieldRoot": "根路径", + "mounts.listTitle": "已挂载", + "mounts.disabled": "已禁用", "mounts.empty": "尚未添加挂载", + "mounts.browseThis": "浏览文件", + "mounts.afterCreateHint": "挂载已保存,可直接浏览其中的视频。", + "mounts.toBrowse": "去浏览", + "mounts.errNameRequired": "请填写挂载名称", + "mounts.errUrlInvalid": "请填写合法的 http(s) 地址,如 https://host/dav", + "mounts.confirmDelete": "确认删除?", + "mounts.deleteHint": "该挂载下已入库的媒体条目会一并移除", + "mounts.deletedN": "挂载已删除(移除 {n} 个媒体条目)", "watch.title": "播放", "watch.loading": "加载中…", @@ -101,6 +155,21 @@ export const zhCN = { "watch.loadErrorHint": "无法加载该媒体,请检查链接或稍后重试。", "watch.notFound": "媒体不存在", "watch.notFoundHint": "该媒体可能已被删除,或你没有访问权限。", + "watch.episodes": "剧集", + "watch.prevEpisode": "上一集", + "watch.nextEpisode": "下一集", + "watch.currentEpisode": "正在播放", + "watch.playNextCta": "播放下一集", + "watch.backToSeries": "返回系列", + "watch.danmakuSettings": "弹幕设置", + "watch.episodeN": "第 {n} 集", + "watch.importOk": "已导入本地弹幕", + "watch.importFail": "弹幕导入失败", + "watch.playFailed": "片源加载失败", + "watch.playFailedHint": "无法读取视频流,可能是挂载已删除或片源不可用。", + "watch.replay": "重新播放", + "watch.playbackFinished": "本集已播放完毕", + "watch.danmakuDisabledHint": "弹幕已关闭,可在上方按钮或弹幕设置中开启", "danmaku.title": "弹幕设置", "danmaku.opacity": "不透明度", @@ -158,7 +227,10 @@ export const zhCN = { "auth.msg.logoutOk": "已退出登录", "auth.msg.passwordOk": "密码已更新", "auth.msg.resetOk": "密码已重置,请登录", - "auth.msg.resetRequested": "若邮箱存在,重置申请已受理", + "auth.msg.resetRequested": + "若邮箱存在,重置申请已受理;重置链接将发送至邮箱(未配置邮件服务时请联系管理员或查看服务日志)", + "auth.passwordHint": "至少 8 位字符", + "auth.forgot.emailLabel": "注册邮箱", "auth.err.generic": "操作失败,请重试", "locale.switch": "语言", diff --git a/packages/models/AGENTS.md b/packages/models/AGENTS.md index db3d86f..c06090c 100644 --- a/packages/models/AGENTS.md +++ b/packages/models/AGENTS.md @@ -8,17 +8,24 @@ ``` src/ - users.ts # users sqliteTable + User / NewUser - sessions.ts # sessions - password-resets.ts # passwordResets + users.ts # users + User / NewUser + sessions.ts # sessions + Session / NewSession + password-resets.ts # passwordResets + PasswordReset / NewPasswordReset + mounts.ts # mounts(网盘挂载:WebDAV 等)+ Mount / NewMount + media-items.ts # mediaItems(媒体条目,唯一键含 mountId)+ MediaItem / NewMediaItem + playback-progress.ts # playbackProgress(观看进度) + danmaku-cache.ts # danmakuCache(弹幕缓存行) index.ts # 重导出全部表与行类型 ``` +域划分:**账户域**(users / sessions / password-resets)与**媒体域**(mounts / media-items / playback-progress / danmaku-cache)。 + ## 约定 - 表用 `sqliteTable`(Turso/libSQL)。主键默认 `text` + `crypto.randomUUID()`。 - 时间戳用 `integer(..., { mode: "timestamp" })`,默认 `unixepoch()`。 - 同步导出 `$inferSelect` / `$inferInsert` 类型(如 `User`、`NewUser`)。 +- `mediaItems` 唯一约束含 `mountId`(同路径可存在于不同挂载)。 ## 修改指南 @@ -26,6 +33,7 @@ src/ - 查询写在 `@app/dao`,不在本包写 `db.select`。 - 不要引入 `@libsql/client` 或环境变量;本包保持纯 schema。 - 重命名/删除列是破坏性变更:先确认无 dao/service/API 依赖,再生成迁移。 +- 新表在 `index.ts` 重导出表 + 行类型,保持命名 `xxx` / `Xxx` / `NewXxx`。 ## 命令 diff --git a/packages/models/src/media-items.ts b/packages/models/src/media-items.ts index 1cde2e6..23f1db6 100644 --- a/packages/models/src/media-items.ts +++ b/packages/models/src/media-items.ts @@ -23,6 +23,8 @@ export const mediaItems = sqliteTable( scrapeStatus: text("scrape_status").notNull().default("pending"), scrapedAt: integer("scraped_at", { mode: "timestamp" }), posterUrl: text("poster_url"), + /** 弹弹play 弹幕库编号(文件识别成功后落库,弹幕拉取直达) */ + dandanplayEpisodeId: integer("dandanplay_episode_id"), matchedHash: text("matched_hash"), scannedAt: integer("scanned_at", { mode: "timestamp" }) .notNull() diff --git a/packages/models/src/users.ts b/packages/models/src/users.ts index 70322e1..03b049e 100644 --- a/packages/models/src/users.ts +++ b/packages/models/src/users.ts @@ -4,14 +4,17 @@ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; /** * users 表。本包只描述结构;连接在 `@app/db`,查询在 `@app/dao`。 * 约定:主键 text + UUID,时间戳 integer(mode: "timestamp") + unixepoch()。 + * 登录标识为 `username`(用户名+密码);`email` 可选,仅用于找回密码。 */ export const users = sqliteTable("users", { id: text("id") .primaryKey() // 由应用层默认生成 UUID,避免依赖数据库扩展 .$defaultFn(() => crypto.randomUUID()), + username: text("username").notNull().unique(), name: text("name").notNull(), - email: text("email").notNull().unique(), + /** 可选邮箱,仅用于找回密码;SQLite unique 允许多个 NULL。 */ + email: text("email").unique(), /** scrypt 输出,格式 `salt:hash`(hex)。default "" 便于给已有行加列。 */ passwordHash: text("password_hash").notNull().default(""), createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), diff --git a/packages/trpc/AGENTS.md b/packages/trpc/AGENTS.md index 5272b9e..e7e6b99 100644 --- a/packages/trpc/AGENTS.md +++ b/packages/trpc/AGENTS.md @@ -4,7 +4,11 @@ API 层:**tRPC 路由 + service 业务逻辑**。service 可不依赖 tRPC 独立调用(脚本/测试);DB 读写经 `@app/dao`,不直接写 SQL。 -脚手架基线域:`user`、`auth`。新业务域在下游仓库按同样模式追加。 +域划分: +- 账户域:`user`、`auth` +- 媒体域:挂在 `media` 路由下的 mount / library / stream / danmaku / scrape / playback + +新域按同样模式追加。 ## 结构 @@ -12,15 +16,26 @@ API 层:**tRPC 路由 + service 业务逻辑**。service 可不依赖 tRPC 独 src/ context.ts # initTRPC 单例 + TrpcContext router/ - index.ts # appRouter 组装(权威定义) + index.ts # appRouter 组装(权威定义:user / auth / media) user.router.ts # user 路由(与 index.ts 内定义重复,改路由时两边同步或收敛到一处) auth.router.ts # auth 路由 + media.router.ts # 媒体域扁平 procedure(mount* / library* / scrape* / playback* / danmaku*) services/ - user.service.ts # user 业务(调 userDao) - auth.service.ts # 会话 / 密码 - password.ts + user.service.ts # userService + auth.service.ts # authService:会话 / 密码 + password.ts # scrypt 哈希(与 @app/db seed 一致) + procedure.ts # protectedProcedure 等 + secret.ts # 媒体流密钥 + mount.service.ts # mountService:WebDAV 挂载 + webdav-client.ts # WebDAV 客户端(含单测) + library.service.ts # libraryService:扫描 / 分组 / 剧集 + stream.service.ts # 流式输出(含单测) + danmaku.service.ts # danmakuService:弹幕拉取 / 导入 / 设置(含单测) + scrape.service.ts # scrapeService:番剧识别 / 绑定(含单测) + playback.service.ts # playbackService:进度上报 / 续播 + load-env.ts server.ts # 独立 HTTP 服务(PORT,默认 4000) - index.ts # 对外:appRouter、类型、userService / authService + index.ts # 对外:appRouter、类型、services router.ts # 兼容旧入口的再导出 ``` @@ -34,12 +49,15 @@ web /api/trpc ──► appRouter ──► service ──► @app/dao 也可 `yarn dev:api` 跑 `server.ts`(独立端口);web SSR 内嵌时通常不用。 +媒体域 procedure 用 `protectedProcedure`(需登录,取 `ctx.userId`);路由层只做 input 映射 + 调 service。 + ## 修改指南 1. **改入参**:先改 `@app/types` 的 Zod schema,再挂到 `t.procedure.input(...)`。 2. **加 procedure**:路由只做 input 映射 + 调 service;业务在 `services/*.service.ts`;SQL 在 `@app/dao`。 -3. **加领域**:新建 `services/x.service.ts` + `router/x.router.ts`(必要时在 `@app/dao` 加 `xDao`),挂到 `appRouter`,并在 `index.ts` 导出。 +3. **加领域**:新建 `services/x.service.ts` + 在 `media.router.ts`(或新 `router/x.router.ts`)挂 procedure,接到 `appRouter`,并在 `index.ts` 导出。 4. 导出给前端的类型用 `RouterInputs` / `RouterOutputs`(来自 `router/index.ts`)。 +5. 外部 API(弹幕/刮削)调用集中在对应 service,注意超时与错误包装;SSRF/路径穿越防护见 `stream.service` / `webdav-client`。 ## 约束 @@ -47,6 +65,7 @@ web /api/trpc ──► appRouter ──► service ──► @app/dao - `noUncheckedIndexedAccess`:判空逻辑在 DAO 内处理(参考 `userDao.create`)。 - 不要在 router/service 里写 SQL;不在 service 里依赖 HTTP/tRPC context(除非鉴权明确需要)。 - `router.ts` 仅兼容旧 import,新代码从 `index.ts` / `./router/index.js` 引入。 +- service 旁的 `*.test.ts` 用 vitest/vite 跑(`vite.config.ts`),改行为时同步补测。 ## 命令 diff --git a/packages/trpc/src/index.ts b/packages/trpc/src/index.ts index 00bfbe3..ec95da6 100644 --- a/packages/trpc/src/index.ts +++ b/packages/trpc/src/index.ts @@ -45,5 +45,5 @@ export { playbackService } from "./services/playback.service.js"; export { danmakuService } from "./services/danmaku.service.js"; // Stream range proxy -export { openRemoteFileStream } from "./services/stream.service.js"; +export { openRemoteFileStream, StreamSourceError } from "./services/stream.service.js"; export type { RemoteFileStream } from "./services/stream.service.js"; diff --git a/packages/trpc/src/router/auth.router.ts b/packages/trpc/src/router/auth.router.ts index d21e7c8..d6f9574 100644 --- a/packages/trpc/src/router/auth.router.ts +++ b/packages/trpc/src/router/auth.router.ts @@ -8,7 +8,7 @@ function mapAuthError(err: unknown): never { const code = err.code === "UNAUTHORIZED" ? "UNAUTHORIZED" - : err.code === "EMAIL_TAKEN" + : err.code === "USERNAME_TAKEN" || err.code === "EMAIL_TAKEN" ? "CONFLICT" : "BAD_REQUEST"; throw new TRPCError({ code, message: err.message, cause: err }); diff --git a/packages/trpc/src/router/media.router.ts b/packages/trpc/src/router/media.router.ts index cd9af25..cf5864a 100644 --- a/packages/trpc/src/router/media.router.ts +++ b/packages/trpc/src/router/media.router.ts @@ -6,7 +6,7 @@ import { scrapeSchemas, } from "@app/types"; import { t } from "../context.js"; -import { danmakuService } from "../services/danmaku.service.js"; +import { danmakuService, hasOpenCredentials } from "../services/danmaku.service.js"; import { libraryService } from "../services/library.service.js"; import { mountService } from "../services/mount.service.js"; import { playbackService } from "../services/playback.service.js"; @@ -36,25 +36,41 @@ export const mediaRouter = t.router({ libraryList: protectedProcedure .input(librarySchemas.list) .query(({ ctx, input }) => libraryService.list(ctx.userId, input)), + libraryGroups: protectedProcedure + .input(librarySchemas.groups) + .query(({ ctx, input }) => libraryService.groups(ctx.userId, input)), libraryGet: protectedProcedure .input(librarySchemas.get) .query(({ ctx, input }) => libraryService.get(ctx.userId, input.id)), libraryScan: protectedProcedure .input(librarySchemas.scan) - .mutation(({ ctx, input }) => libraryService.scan(ctx.userId, input.mountId)), + .mutation(({ ctx, input }) => libraryService.scan(ctx.userId, input.mountId, input.path)), libraryEnsurePath: protectedProcedure .input(librarySchemas.ensurePath) .mutation(({ ctx, input }) => libraryService.ensurePath(ctx.userId, input.mountId, input.path), ), + librarySeries: protectedProcedure + .input(librarySchemas.series) + .query(({ ctx, input }) => + libraryService.series(ctx.userId, input.bangumiId, input.mountId), + ), scrapeSearch: protectedProcedure .input(scrapeSchemas.search) .query(({ input }) => scrapeService.search(input.q)), + scrapeDandanMatch: protectedProcedure + .input(danmakuSchemas.match) + .mutation(({ ctx, input }) => scrapeService.matchDandanplay(ctx.userId, input.mediaItemId)), scrapeBind: protectedProcedure .input(scrapeSchemas.bind) .mutation(({ ctx, input }) => - scrapeService.bind(ctx.userId, input.mediaItemId, input.bangumiId), + scrapeService.bind(ctx.userId, input.mediaItemId, input.bangumiId, input.epNumber), + ), + scrapeBindPath: protectedProcedure + .input(scrapeSchemas.bindPath) + .mutation(({ ctx, input }) => + scrapeService.bindPath(ctx.userId, input.mountId, input.path, input.bangumiId), ), scrapeRescrape: protectedProcedure .input(scrapeSchemas.rescrape) @@ -63,6 +79,11 @@ export const mediaRouter = t.router({ .autoScrape(ctx.userId, input.mediaItemId) .then(() => ({ success: true as const })), ), + scrapeRescrapePath: protectedProcedure + .input(scrapeSchemas.rescrapePath) + .mutation(({ ctx, input }) => + scrapeService.rescrapePath(ctx.userId, input.mountId, input.path), + ), playbackReport: protectedProcedure .input(playbackSchemas.report) @@ -70,6 +91,9 @@ export const mediaRouter = t.router({ playbackGet: protectedProcedure .input(playbackSchemas.get) .query(({ ctx, input }) => playbackService.get(ctx.userId, input.mediaItemId)), + playbackContinue: protectedProcedure + .input(playbackSchemas.continueList) + .query(({ ctx, input }) => playbackService.continueList(ctx.userId, input.limit)), danmakuFetch: protectedProcedure .input(danmakuSchemas.fetch) @@ -83,6 +107,7 @@ export const mediaRouter = t.router({ enabled: true, opacity: 0.8, density: 1, + openNetworkConfigured: hasOpenCredentials(), })), danmakuSaveSettings: protectedProcedure .input(danmakuSchemas.settings) diff --git a/packages/trpc/src/services/auth.service.ts b/packages/trpc/src/services/auth.service.ts index 7801d67..c4efbd8 100644 --- a/packages/trpc/src/services/auth.service.ts +++ b/packages/trpc/src/services/auth.service.ts @@ -1,5 +1,6 @@ /** * auth 域业务:注册 / 登录 / 登出 / 会话 / 改密 / 找回密码。 + * 默认认证方式:用户名 + 密码。 * 可不依赖 tRPC 直接调用;DB 经 `@app/dao`。cookie 写入通过可选 `setCookie` 回调注入。 */ import { randomBytes } from "node:crypto"; @@ -26,12 +27,13 @@ export class AuthError extends Error { } export interface RegisterInput { - name: string; - email: string; + username: string; password: string; + name?: string | undefined; + email?: string | undefined; } export interface LoginInput { - email: string; + username: string; password: string; } export interface ChangePasswordInput { @@ -52,13 +54,47 @@ export interface ResetPasswordInput { } function toAuthUser(row: UserRow): AuthUser { - return { id: row.id, name: row.name, email: row.email }; + return { id: row.id, username: row.username, name: row.name, email: row.email }; } function newToken(): string { return randomBytes(32).toString("base64url"); } +/** + * 重置令牌交付通道:配置 PASSWORD_RESET_WEBHOOK_URL 时 POST JSON + * { email, username, token, resetPath }(可对接任意邮件/通知服务); + * 未配置时写入服务日志,保证生产环境流程仍有出路(令牌 30 分钟有效)。 + */ +async function deliverResetToken(p: { + username: string; + email: string; + token: string; +}): Promise<void> { + const resetPath = `/auth/reset-password?token=${encodeURIComponent(p.token)}`; + const webhook = process.env["PASSWORD_RESET_WEBHOOK_URL"]; + if (webhook) { + try { + await fetch(webhook, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: p.email, + username: p.username, + token: p.token, + resetPath, + }), + signal: AbortSignal.timeout(5000), + }); + } catch (e) { + console.warn(`[password-reset] webhook failed: ${e instanceof Error ? e.message : e}`); + } + } + console.warn( + `[password-reset] user=${p.username} email=${p.email} token=${p.token} link=${resetPath}`, + ); +} + export function sessionCookie(token: string): string { const secure = IS_PROD ? "; Secure" : ""; return `${COOKIE_SESSION}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE_SECONDS}${secure}`; @@ -96,12 +132,18 @@ async function resolveSessionUser(token: string | undefined): Promise<AuthUser | export const authService = { async register(input: RegisterInput, scope: SessionScope = {}): Promise<{ user: AuthUser }> { - const email = input.email.toLowerCase(); - const existing = await userDao.getByEmail(email); - if (existing) throw new AuthError("EMAIL_TAKEN", "该邮箱已被注册"); + const username = input.username.toLowerCase(); + const existing = await userDao.getByUsername(username); + if (existing) throw new AuthError("USERNAME_TAKEN", "该用户名已被占用"); + const email = input.email?.toLowerCase(); + if (email) { + const emailTaken = await userDao.getByEmail(email); + if (emailTaken) throw new AuthError("EMAIL_TAKEN", "该邮箱已被注册"); + } const user = await userDao.create({ - name: input.name, - email, + username, + name: input.name?.trim() || username, + email: email ?? null, passwordHash: hashPassword(input.password), }); await createSession(user.id, scope.setCookie); @@ -109,10 +151,10 @@ export const authService = { }, async login(input: LoginInput, scope: SessionScope = {}): Promise<{ user: AuthUser }> { - const user = await userDao.getByEmail(input.email); - // 统一错误,避免邮箱枚举 + const user = await userDao.getByUsername(input.username); + // 统一错误,避免用户名枚举 if (!user || !verifyPassword(input.password, user.passwordHash)) { - throw new AuthError("INVALID_CREDENTIALS", "邮箱或密码不正确"); + throw new AuthError("INVALID_CREDENTIALS", "用户名或密码不正确"); } await createSession(user.id, scope.setCookie); return { user: toAuthUser(user) }; @@ -163,6 +205,11 @@ export const authService = { token, expiresAt: new Date(Date.now() + RESET_TTL_MS), }); + await deliverResetToken({ + username: user.username, + email: user.email ?? input.email, + token, + }); if (IS_PROD) return {}; return { resetToken: token }; }, diff --git a/packages/trpc/src/services/danmaku.service.test.ts b/packages/trpc/src/services/danmaku.service.test.ts index f0ac4f4..9b68fad 100644 --- a/packages/trpc/src/services/danmaku.service.test.ts +++ b/packages/trpc/src/services/danmaku.service.test.ts @@ -1,6 +1,12 @@ import { createHash } from "node:crypto"; import { describe, expect, it } from "vitest"; -import { generateOpenSignature, headMd5, openCommentsToXml } from "./danmaku.service.js"; +import { + generateOpenSignature, + headMd5, + matchFileName, + openCommentsToXml, + parseMatchOutcome, +} from "./danmaku.service.js"; describe("danmaku open network helpers", () => { it("headMd5 matches md5 of sample", () => { @@ -39,3 +45,54 @@ describe("danmaku open network helpers", () => { ).toBe('<?xml version="1.0" encoding="UTF-8"?><i></i>'); }); }); + +describe("matchFileName", () => { + it("去文件夹路径与扩展名(官方 match 要求)", () => { + expect(matchFileName("/anime/[Sub] Title - 01 [1080p].mkv")).toBe( + "[Sub] Title - 01 [1080p]", + ); + }); + it("点开头的隐藏文件不算扩展名", () => { + expect(matchFileName(".hidden")).toBe(".hidden"); + }); + it("无扩展名原样保留", () => { + expect(matchFileName("Movie")).toBe("Movie"); + }); +}); + +describe("parseMatchOutcome", () => { + it("提取候选及其刮削元数据", () => { + const r = parseMatchOutcome({ + isMatched: true, + matches: [ + { + episodeId: 111, + animeId: 9, + animeTitle: "葬送的芙莉莲", + episodeTitle: "第1话 冒险终结之地", + imageUrl: "http://img/1.jpg", + }, + ], + }); + expect(r.isMatched).toBe(true); + expect(r.candidates[0]?.episodeId).toBe(111); + expect(r.candidates[0]?.animeId).toBe(9); + expect(r.candidates[0]?.animeTitle).toBe("葬送的芙莉莲"); + expect(r.candidates[0]?.imageUrl).toBe("http://img/1.jpg"); + }); + it("episodeId 缺失的条目丢弃,字符串 id 可解析", () => { + const r = parseMatchOutcome({ + isMatched: false, + matches: [{ episodeId: "222" }, { animeTitle: "no id" }], + }); + expect(r.isMatched).toBe(false); + expect(r.candidates).toHaveLength(1); + expect(r.candidates[0]?.episodeId).toBe(222); + expect(r.candidates[0]?.animeTitle).toBeNull(); + }); + it("空响应安全兜底", () => { + const r = parseMatchOutcome({}); + expect(r.isMatched).toBe(false); + expect(r.candidates).toEqual([]); + }); +}); diff --git a/packages/trpc/src/services/danmaku.service.ts b/packages/trpc/src/services/danmaku.service.ts index 8b1e3c0..322fac4 100644 --- a/packages/trpc/src/services/danmaku.service.ts +++ b/packages/trpc/src/services/danmaku.service.ts @@ -3,7 +3,7 @@ import { TRPCError } from "@trpc/server"; import { danmakuCacheDao, mediaItemDao, mountDao } from "@app/dao"; import type { DanmakuFetchOutput } from "@app/types"; import { decryptSecret } from "./secret.js"; -import { createWebdav } from "./webdav-client.js"; +import { createWebdav, joinWebdavPath } from "./webdav-client.js"; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; /** 官方文件识别 hash:文件前 16MB 的 MD5(doc.dandanplay.com/open) */ @@ -17,6 +17,22 @@ type OpenComment = { time?: number; }; +/** MatchResultV2 刮削子集(swagger:episodeId/animeId/animeTitle/episodeTitle/imageUrl) */ +export type OpenMatchCandidate = { + episodeId: number; + animeId: number | null; + animeTitle: string | null; + episodeTitle: string | null; + imageUrl: string | null; +}; + +export type OpenMatchOutcome = { + ok: boolean; + errorMessage: string | null; + isMatched: boolean; + candidates: OpenMatchCandidate[]; +}; + function openApiBase(): string { return (process.env["OPEN_DANMAKU_API_BASE"] ?? "https://api.dandanplay.net").replace( /\/+$/, @@ -31,6 +47,11 @@ function openCredentials(): { appId: string; appSecret: string } | null { return { appId, appSecret }; } +/** 凭据是否已配置;未配置时开放网络整体不可用,UI 需明示而非静默降级 */ +export function hasOpenCredentials(): boolean { + return openCredentials() !== null; +} + /** 官方签名:base64(sha256(AppId + Timestamp + Path + AppSecret)) */ export function generateOpenSignature( appId: string, @@ -46,6 +67,13 @@ export function headMd5(sample: Buffer): string { return createHash("md5").update(sample).digest("hex"); } +/** 官方 match 要求 fileName 不含文件夹路径与扩展名;点开头的隐藏文件不算扩展名 */ +export function matchFileName(path: string): string { + const base = path.split("/").pop() ?? path; + const dot = base.lastIndexOf("."); + return dot > 0 ? base.slice(0, dot) : base; +} + function escapeXml(s: string): string { return s .replace(/&/g, "&") @@ -78,23 +106,130 @@ export function openCommentsToXml(comments: OpenComment[]): string { } function openHeaders(path: string): Record<string, string> { + const headers: Record<string, string> = { "Content-Type": "application/json" }; const creds = openCredentials(); - if (!creds) return {}; + if (!creds) return headers; const timestamp = Math.floor(Date.now() / 1000); return { - "Content-Type": "application/json", + ...headers, "X-AppId": creds.appId, "X-Timestamp": String(timestamp), "X-Signature": generateOpenSignature(creds.appId, timestamp, path, creds.appSecret), }; } +type OpenApiBase = { + errorCode?: number; + success?: boolean; + errorMessage?: string | null; + errorDetail?: string | null; +}; + +/** 开放网络统一请求:拼 base + 鉴权头;HTTP 非 2xx / success=false / errorCode≠0 / 网络错误统一归到 ok:false */ +export async function openFetchJson( + path: string, + init?: { method?: "POST"; body?: string } | undefined, +): Promise<{ ok: true; json: unknown } | { ok: false; status: number | null; error: string }> { + try { + const res = await fetch(`${openApiBase()}${path}`, { + method: init?.method ?? "GET", + headers: openHeaders(path), + ...(init?.body !== undefined ? { body: init.body } : {}), + }); + if (!res.ok) { + return { ok: false, status: res.status, error: `弹弹play API HTTP ${res.status}` }; + } + const json: unknown = await res.json(); + const base = json as OpenApiBase; + if (base.success === false || (base.errorCode ?? 0) !== 0) { + return { + ok: false, + status: res.status, + error: base.errorMessage || base.errorDetail || "弹弹play API 调用失败", + }; + } + return { ok: true, json }; + } catch (e) { + return { + ok: false, + status: null, + error: e instanceof Error ? e.message : "弹弹play API 网络错误", + }; + } +} + +type RawMatch = { + episodeId?: number | string; + animeId?: number | string; + animeTitle?: string | null; + episodeTitle?: string | null; + imageUrl?: string | null; +}; + +/** 解析 /api/v2/match 响应体为候选列表(纯函数便于单测);无 episodeId 的条目丢弃 */ +export function parseMatchOutcome(json: unknown): { + isMatched: boolean; + candidates: OpenMatchCandidate[]; +} { + const body = json as { isMatched?: boolean; matches?: RawMatch[] }; + const candidates: OpenMatchCandidate[] = []; + for (const m of body.matches ?? []) { + const episodeId = + typeof m.episodeId === "number" + ? m.episodeId + : Number.parseInt(String(m.episodeId ?? ""), 10); + if (!Number.isFinite(episodeId)) continue; + const animeId = + typeof m.animeId === "number" + ? m.animeId + : Number.parseInt(String(m.animeId ?? ""), 10); + candidates.push({ + episodeId, + animeId: Number.isFinite(animeId) ? animeId : null, + animeTitle: m.animeTitle ?? null, + episodeTitle: m.episodeTitle ?? null, + imageUrl: m.imageUrl ?? null, + }); + } + return { isMatched: body.isMatched === true, candidates }; +} + +/** POST /api/v2/match;错误透出 errorMessage,不再吞成 null */ +export async function openMatch( + fileName: string, + fileSize: number, + fileHash: string | null, +): Promise<OpenMatchOutcome> { + const res = await openFetchJson("/api/v2/match", { + method: "POST", + body: JSON.stringify({ + fileName, + fileHash: fileHash ?? "", + fileSize, + matchMode: fileHash ? "hashAndFileName" : "fileNameOnly", + }), + }); + if (!res.ok) { + return { ok: false, errorMessage: res.error, isMatched: false, candidates: [] }; + } + const parsed = parseMatchOutcome(res.json); + return { ok: true, errorMessage: null, ...parsed }; +} + +/** GET /api/v2/comment/{episodeId}?withRelated=true → Bilibili 式 XML */ +export async function openCommentXml(episodeId: number): Promise<string | null> { + const res = await openFetchJson(`/api/v2/comment/${episodeId}?withRelated=true`); + if (!res.ok) return null; + const body = res.json as { comments?: OpenComment[] }; + return openCommentsToXml(body.comments ?? []); +} + function matchKeyFor(path: string, size: number): string { return createHash("sha256").update(`${path}:${size}`).digest("hex"); } /** 取 WebDAV 文件前 16MB 的 MD5(开放网络文件识别用) */ -async function remoteFileHash(userId: string, mediaId: string): Promise<string | null> { +export async function remoteFileHash(userId: string, mediaId: string): Promise<string | null> { const item = await mediaItemDao.getByIdForUser(mediaId, userId); if (!item?.mountId) return null; const mount = await mountDao.getByIdForUser(item.mountId, userId); @@ -105,7 +240,7 @@ async function remoteFileHash(userId: string, mediaId: string): Promise<string | password: mount.secretEnc ? decryptSecret(mount.secretEnc) : undefined, }); try { - const abs = mount.rootPath.replace(/\/+$/, "") + item.path; + const abs = joinWebdavPath(mount.rootPath || "/", item.path); const stream = client.createReadStream(abs, { range: { start: 0, end: HASH_HEAD_BYTES - 1 }, }); @@ -123,55 +258,19 @@ async function remoteFileHash(userId: string, mediaId: string): Promise<string | } } -async function fetchOpenNetworkXml( - path: string, - size: number, - hash: string | null, -): Promise<string | null> { - const base = openApiBase(); - const matchPath = "/api/v2/match"; - try { - const matchRes = await fetch(`${base}${matchPath}`, { - method: "POST", - headers: openHeaders(matchPath), - body: JSON.stringify({ - fileName: path.split("/").pop() ?? path, - fileHash: hash ?? "", - fileSize: size, - matchMode: hash ? "hashAndFileName" : "fileNameOnly", - }), - }); - if (!matchRes.ok) return null; - const matched = (await matchRes.json()) as { - isMatched?: boolean; - success?: boolean; - matches?: Array<{ episodeId?: number | string }>; - }; - if (matched.success === false) return null; - const episodeId = matched.matches?.[0]?.episodeId; - if (!episodeId) return null; - if (matched.isMatched === false) return null; - - const commentPath = `/api/v2/comment/${episodeId}`; - const commentRes = await fetch(`${base}${commentPath}?withRelated=true`, { - headers: openHeaders(commentPath), - }); - if (!commentRes.ok) return null; - const body = (await commentRes.json()) as { comments?: OpenComment[] }; - const comments = body.comments ?? []; - return openCommentsToXml(comments); - } catch { - return null; - } -} - export const danmakuService = { async fetch(userId: string, mediaItemId: string): Promise<DanmakuFetchOutput> { const item = await mediaItemDao.getByIdForUser(mediaItemId, userId); if (!item) return { ok: false, source: "none", xml: "", message: "媒体不存在" }; if (!openCredentials()) { - return { ok: true, source: "none", xml: "", message: "未配置开放网络" }; + return { + ok: true, + source: "none", + xml: "", + message: + "未配置开放弹幕网络:服务端需设置 OPEN_DANMAKU_APP_ID / OPEN_DANMAKU_APP_SECRET", + }; } const matchKey = matchKeyFor(item.path, item.size); @@ -180,10 +279,30 @@ export const danmakuService = { return { ok: true, source: "cache", xml: cached.payload }; } - const hash = await remoteFileHash(userId, mediaItemId); - const xml = await fetchOpenNetworkXml(item.path, item.size, hash); + let episodeId = item.dandanplayEpisodeId ?? null; + if (episodeId == null) { + const hash = await remoteFileHash(userId, mediaItemId); + const outcome = await openMatch(matchFileName(item.path), item.size, hash); + const best = outcome.candidates[0]; + if (!outcome.ok || !outcome.isMatched || !best) { + return { + ok: true, + source: "none", + xml: "", + message: outcome.errorMessage ?? "开放网络未匹配到弹幕", + }; + } + episodeId = best.episodeId; + // 识别身份落库:后续播放直接 comment,免重跑 16MB 读流 + match(元数据覆盖走库页识别入口) + await mediaItemDao.setDanmakuMatch(mediaItemId, userId, { + episodeId, + matchedHash: hash, + }); + } + + const xml = await openCommentXml(episodeId); if (xml === null) { - return { ok: true, source: "none", xml: "", message: "开放网络未匹配到弹幕" }; + return { ok: true, source: "none", xml: "", message: "弹幕库拉取失败" }; } await danmakuCacheDao.upsert({ matchKey, diff --git a/packages/trpc/src/services/library.service.ts b/packages/trpc/src/services/library.service.ts index 43b8cd0..348a58a 100644 --- a/packages/trpc/src/services/library.service.ts +++ b/packages/trpc/src/services/library.service.ts @@ -1,5 +1,6 @@ import { TRPCError } from "@trpc/server"; -import { mediaItemDao, mountDao, type MediaItemRow } from "@app/dao"; +import { mediaItemDao, mountDao, playbackProgressDao, type MediaItemRow } from "@app/dao"; +import type { LibraryGroupPage } from "@app/types"; import { decryptSecret } from "./secret.js"; import { createWebdav, isVideoFilename, joinWebdavPath, listDirectory } from "./webdav-client.js"; import { scrapeService } from "./scrape.service.js"; @@ -58,31 +59,89 @@ async function walkVideos( } export const libraryService = { + /** 惰性兜底:挂载删除后残留的孤儿条目/进度(级联为主,这里清历史脏数据) */ + async cleanupOrphans(): Promise<void> { + const removed = await mediaItemDao.cleanupOrphans(); + if (removed > 0) await playbackProgressDao.cleanupOrphans(); + }, + async list( userId: string, params: { limit: number; offset: number; + mountId?: string | undefined; scrapeStatus?: string | undefined; q?: string | undefined; }, ) { + await this.cleanupOrphans(); return mediaItemDao.list(userId, params); }, + /** 海报墙:指定挂载下已刮削条目按 bangumiId 分组的分页摘要(剧集详情按需走 series) */ + async groups( + userId: string, + params: { + mountId: string; + q?: string | undefined; + limit: number; + offset: number; + }, + ): Promise<LibraryGroupPage> { + await this.cleanupOrphans(); + const mount = await mountDao.getByIdForUser(params.mountId, userId); + if (!mount) throw new TRPCError({ code: "NOT_FOUND", message: "挂载不存在" }); + const { rows, total } = await mediaItemDao.listScrapedGroups(userId, params); + return { + total, + groups: rows.map((row) => ({ + bangumiId: row.bangumiId, + title: row.title, + posterUrl: row.posterUrl, + episodeCount: row.episodeCount, + updatedAt: row.updatedAt.toISOString(), + })), + }; + }, + async get(userId: string, id: string): Promise<MediaItemRow> { + await this.cleanupOrphans(); const row = await mediaItemDao.getByIdForUser(id, userId); if (!row) throw new TRPCError({ code: "NOT_FOUND", message: "媒体不存在" }); return row; }, - async scan(userId: string, mountId: string): Promise<{ scanned: number; message: string }> { + /** 同作品剧集(按集数升序);mountId 可选:库内详情只看该挂载,播放页不传跨挂载 */ + async series(userId: string, bangumiId: number, mountId?: string | undefined) { + await this.cleanupOrphans(); + const rows = await mediaItemDao.listByBangumiId(userId, bangumiId, mountId); + const first = rows[0]; + return { + bangumiId, + title: first?.title ?? "", + posterUrl: first?.posterUrl ?? null, + episodes: rows.map((row) => ({ + id: row.id, + title: row.title, + epNumber: row.epNumber, + rawName: row.rawName, + })), + }; + }, + + async scan( + userId: string, + mountId: string, + path?: string | undefined, + ): Promise<{ scanned: number; message: string }> { const mount = await mountDao.getByIdForUser(mountId, userId); if (!mount) throw new TRPCError({ code: "NOT_FOUND", message: "挂载不存在" }); if (!mount.enabled) throw new TRPCError({ code: "BAD_REQUEST", message: "挂载已禁用" }); const budget = { left: MAX_SCAN_FILES }; const jobs: ScanJob[] = []; - await walkVideos(userId, mountId, mount.rootPath || "/", "/", budget, jobs); + const startRel = path && path !== "/" ? path : "/"; + await walkVideos(userId, mountId, mount.rootPath || "/", startRel, budget, jobs); for (let i = 0; i < jobs.length; i += SCRAPE_CONCURRENCY) { const batch = jobs.slice(i, i + SCRAPE_CONCURRENCY).map((job) => job()); await Promise.allSettled(batch); diff --git a/packages/trpc/src/services/mount.service.ts b/packages/trpc/src/services/mount.service.ts index dc80009..b3ac9d3 100644 --- a/packages/trpc/src/services/mount.service.ts +++ b/packages/trpc/src/services/mount.service.ts @@ -1,5 +1,5 @@ import { TRPCError } from "@trpc/server"; -import { mountDao, type MountRow } from "@app/dao"; +import { mediaItemDao, mountDao, playbackProgressDao, type MountRow } from "@app/dao"; import type { MountCreateInput, MountListDirInput } from "@app/types"; import { decryptSecret, encryptSecret } from "./secret.js"; import { @@ -126,9 +126,13 @@ export const mountService = { return { mount: toPublic(row) }; }, - async delete(userId: string, id: string): Promise<{ success: true }> { + async delete(userId: string, id: string): Promise<{ success: true; removedItems: number }> { + const mount = await mountDao.getByIdForUser(id, userId); + if (!mount) throw new TRPCError({ code: "NOT_FOUND", message: "挂载不存在" }); + const removedIds = await mediaItemDao.deleteByMount(id, userId); + await playbackProgressDao.deleteByMediaItems(removedIds); await mountDao.delete(id, userId); - return { success: true }; + return { success: true, removedItems: removedIds.length }; }, async test(input: { @@ -150,8 +154,8 @@ export const mountService = { }); await listDirectory(client, input.rootPath || "/"); return { ok: true, message: "连接成功" }; - } catch { - return { ok: false, message: "连接失败" }; + } catch (e) { + return { ok: false, message: describeWebdavError(e) }; } }, @@ -169,11 +173,35 @@ export const mountService = { return a.basename.localeCompare(b.basename); }); return { entries, path: input.path }; - } catch { + } catch (e) { throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "读取目录失败", + code: "BAD_GATEWAY", + message: describeWebdavError(e), }); } }, }; + +/** 把 webdav/网络底层错误归一化成用户可读的失败原因 */ +function describeWebdavError(e: unknown): string { + const raw = e instanceof Error ? e.message : String(e); + const s = raw.toLowerCase(); + if ( + s.includes("401") || + s.includes("403") || + s.includes("unauthorized") || + s.includes("forbidden") + ) + return "认证失败,请检查用户名和密码"; + if (s.includes("enotfound") || s.includes("eai_again") || s.includes("getaddrinfo")) + return "无法解析服务器地址"; + if (s.includes("econnrefused")) return "连接被拒绝,请检查地址和端口"; + if (s.includes("etimedout") || s.includes("timeout") || s.includes("aborted")) + return "连接超时"; + if (s.includes("certificate") || s.includes("ssl") || s.includes("tls")) + return "HTTPS 证书错误"; + if (s.includes("404") || s.includes("405") || s.includes("not found")) + return "地址不可用(可能不是 WebDAV 服务)"; + const short = raw.length > 120 ? `${raw.slice(0, 120)}…` : raw; + return `连接失败:${short}`; +} diff --git a/packages/trpc/src/services/playback.service.ts b/packages/trpc/src/services/playback.service.ts index 3547ca7..245ab60 100644 --- a/packages/trpc/src/services/playback.service.ts +++ b/packages/trpc/src/services/playback.service.ts @@ -1,6 +1,6 @@ import { TRPCError } from "@trpc/server"; import { mediaItemDao, playbackProgressDao } from "@app/dao"; -import type { PlaybackReportInput } from "@app/types"; +import type { ContinueItem, PlaybackReportInput } from "@app/types"; export const playbackService = { async report(userId: string, input: PlaybackReportInput) { @@ -29,4 +29,22 @@ export const playbackService = { updatedAt: row.updatedAt, }; }, + /** 未看完的最近条目,供首页/媒体库「继续观看」入口 */ + async continueList(userId: string, limit: number): Promise<ContinueItem[]> { + const rows = await playbackProgressDao.listUnfinished(userId, limit); + const out: ContinueItem[] = []; + for (const row of rows) { + const item = await mediaItemDao.getByIdForUser(row.mediaItemId, userId); + if (!item) continue; + out.push({ + mediaItemId: item.id, + title: item.title, + posterUrl: item.posterUrl, + positionMs: row.positionMs, + durationMs: row.durationMs, + updatedAt: row.updatedAt.toISOString(), + }); + } + return out; + }, }; diff --git a/packages/trpc/src/services/scrape.service.test.ts b/packages/trpc/src/services/scrape.service.test.ts index 645ed01..f379cca 100644 --- a/packages/trpc/src/services/scrape.service.test.ts +++ b/packages/trpc/src/services/scrape.service.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { buildSearchQuery, parseEpisodeFromFilename } from "./scrape.service.js"; +import { + AUTO_BIND_THRESHOLD, + buildSearchQuery, + parseEpisodeFromFilename, + pickDandanEpisode, + scoreHit, +} from "./scrape.service.js"; describe("parseEpisodeFromFilename", () => { it("S01E02", () => { @@ -8,6 +14,15 @@ describe("parseEpisodeFromFilename", () => { it("[Group] Title - 05 [1080p].mkv", () => { expect(parseEpisodeFromFilename("[Group] Title - 05 [1080p].mkv")).toBe(5); }); + it("第12集", () => { + expect(parseEpisodeFromFilename("进击的巨人 第12集.mkv")).toBe(12); + }); + it("EP03", () => { + expect(parseEpisodeFromFilename("Title EP03.mkv")).toBe(3); + }); + it("[01] 标签式集数", () => { + expect(parseEpisodeFromFilename("[Sub] Title [01][1080p].mkv")).toBe(1); + }); it("无集数", () => { expect(parseEpisodeFromFilename("Movie.mkv")).toBeNull(); }); @@ -18,4 +33,59 @@ describe("buildSearchQuery", () => { expect(buildSearchQuery("[Sub] Bocchi the Rock! - 01.mkv")).toContain("Bocchi"); expect(buildSearchQuery("[Sub] Bocchi the Rock! - 01.mkv")).not.toContain(".mkv"); }); + it("去标签与集数", () => { + const q = buildSearchQuery("[幻樱] 葬送的芙莉莲 [12][1080p][x265].mkv"); + expect(q).toContain("葬送的芙莉莲"); + expect(q).not.toContain("1080p"); + expect(q).not.toContain("12"); + }); +}); + +describe("pickDandanEpisode", () => { + const eps = [ + { episodeId: 11, episodeTitle: "ep1", episodeNumber: "1" }, + { episodeId: 12, episodeTitle: "ep2", episodeNumber: "2" }, + { episodeId: 13, episodeTitle: "ep3", episodeNumber: "3" }, + ]; + it("episodeNumber 数值相等优先", () => { + expect(pickDandanEpisode(eps, 2)?.episodeId).toBe(12); + }); + it("episodeNumber 非数值时按位序回退", () => { + expect( + pickDandanEpisode([{ episodeId: 21, episodeTitle: "a", episodeNumber: "第1话" }], 1) + ?.episodeId, + ).toBe(21); + }); + it("epNumber 越界不瞎猜", () => { + expect(pickDandanEpisode(eps, 9)).toBeNull(); + }); + it("epNumber 未知取第 1 条", () => { + expect(pickDandanEpisode(eps, null)?.episodeId).toBe(11); + }); + it("空列表返回 null", () => { + expect(pickDandanEpisode([], 1)).toBeNull(); + }); + it("episodeId 为字符串也能解析", () => { + expect(pickDandanEpisode([{ episodeId: "31", episodeNumber: "1" }], 1)?.episodeId).toBe(31); + }); +}); + +describe("scoreHit", () => { + it("完全命中最高", () => { + expect( + scoreHit("葬送的芙莉莲", { name: "Sousou no Frieren", nameCn: "葬送的芙莉莲" }), + ).toBe(1); + }); + it("包含关系中等偏上", () => { + const s = scoreHit("葬送的芙莉莲", { + name: "Sousou no Frieren", + nameCn: "葬送的芙莉莲 第二季", + }); + expect(s).toBeGreaterThan(0.7); + expect(s).toBeLessThan(1); + }); + it("无关条目应低于自动绑定阈值", () => { + const s = scoreHit("葬送的芙莉莲", { name: "Naruto", nameCn: "火影忍者" }); + expect(s).toBeLessThan(AUTO_BIND_THRESHOLD); + }); }); diff --git a/packages/trpc/src/services/scrape.service.ts b/packages/trpc/src/services/scrape.service.ts index eb8e6bf..005b04c 100644 --- a/packages/trpc/src/services/scrape.service.ts +++ b/packages/trpc/src/services/scrape.service.ts @@ -1,24 +1,85 @@ +import { TRPCError } from "@trpc/server"; import { mediaItemDao } from "@app/dao"; +import type { BatchResult, DanmakuMatchOutput } from "@app/types"; +import { + hasOpenCredentials, + matchFileName, + openFetchJson, + openMatch, + remoteFileHash, +} from "./danmaku.service.js"; +/** 从文件名提取集数:SxxExx / - NN / EPxx / 第N集·话 / [NN] / Exx */ export function parseEpisodeFromFilename(name: string): number | null { const s = name.replace(/\.[a-z0-9]+$/i, ""); - const m = - s.match(/S\d{1,2}\s*E(\d{1,3})/i) || - s.match(/(?:^|[\s\-_])EP?(\d{1,3})(?:[\s\-_]|$)/i) || - s.match(/\s-\s(\d{2,3})(?:\s|$)/); - if (!m?.[1]) return null; - const n = Number.parseInt(m[1], 10); - return Number.isFinite(n) ? n : null; + const patterns: RegExp[] = [ + /S\d{1,2}\s*E(\d{1,3})/i, + /(?:^|[\s\-_[·])EP?(\d{1,4})(?:[\s\-_\]·]|$)/i, + /第\s*(\d{1,4})\s*[集话話]/, + /\[(\d{1,3})\]/, + /\s-\s(\d{2,3})(?:\s|$|v\d)/i, + ]; + for (const re of patterns) { + const m = s.match(re); + if (m?.[1]) { + const n = Number.parseInt(m[1], 10); + if (Number.isFinite(n)) return n; + } + } + return null; } +/** 从文件名剥出作品检索词:去扩展名/字幕组/集数/分辨率等噪声 */ export function buildSearchQuery(filename: string): string { let s = filename.replace(/\.[a-z0-9]+$/i, ""); s = s.replace(/^\[[^\]]*\]\s*/, ""); - s = s.replace(/\s*-\s*\d{2,3}.*$/, ""); - s = s.replace(/\b(1080p|720p|2160p|4k|x264|x265|hevc|aac|flac)\b/gi, " "); - return s.replace(/\s+/g, " ").trim(); + s = s.replace(/\[[^\]]*\]/g, " "); + s = s.replace(/[((][^))]*[))]/g, " "); + // 集数标记 + s = s.replace(/S\d{1,2}\s*E\d{1,3}/i, " "); + s = s.replace(/第\s*\d{1,4}\s*[集话話]/, " "); + s = s.replace(/(?:^|[\s\-_])EP?\d{1,4}(?:[\s\-_]|$)/i, " "); + s = s.replace(/\s-\s\d{2,3}(?:\s|$|v\d).*$/i, " "); + // 清晰度/编码/字幕组残留 + s = s.replace( + /\b(1080p|720p|2160p|4k|x264|x265|hevc|aac|flac|10bit|8bit|bdrip|webrip|web-dl|bluray|aac\d\.?\d?|dual|简体|繁体|中字)\b/gi, + " ", + ); + s = s.replace(/[【】[\]()()]/g, " "); + return s.replace(/[\s\-_·・.]+/g, " ").trim(); } +/** 归一化标题,便于模糊比对 */ +function normalizeTitle(s: string): string { + return s + .toLowerCase() + .replace(/[\s\-_·・.。!!??:::、,,'"]/g, "") + .replace(/[((][^))]*[))]/g, ""); +} + +/** 候选与检索词的相似度 0–1;借鉴弹弹play「候选列表+择优」而非盲取第一条 */ +export function scoreHit(query: string, hit: { name: string; nameCn: string }): number { + const q = normalizeTitle(query); + if (!q) return 0; + const cn = normalizeTitle(hit.nameCn); + const jp = normalizeTitle(hit.name); + if (cn === q || jp === q) return 1; + if (cn && (cn.includes(q) || q.includes(cn))) return 0.82; + if (jp && (jp.includes(q) || q.includes(jp))) return 0.78; + // token 重叠 + const qTokens = new Set(query.toLowerCase().split(/\s+/).filter(Boolean)); + const hitTokens = new Set( + `${hit.nameCn} ${hit.name}`.toLowerCase().split(/\s+/).filter(Boolean), + ); + if (qTokens.size === 0 || hitTokens.size === 0) return 0.2; + let overlap = 0; + for (const tok of qTokens) if (hitTokens.has(tok)) overlap += 1; + return 0.3 + 0.5 * (overlap / qTokens.size); +} + +/** 自动绑定阈值:低于此值留给手动匹配 */ +export const AUTO_BIND_THRESHOLD = 0.55; + export type BangumiSearchHit = { id: number; name: string; @@ -28,6 +89,64 @@ export type BangumiSearchHit = { summary: string; }; +type RawDandanEpisode = { + episodeId?: number | string; + episodeTitle?: string | null; + episodeNumber?: string | null; +}; + +/** + * bgmtv 桥择集:episodeNumber 数值相等优先,其次按位序取第 N 条。 + * epNumber 越界时不瞎猜返回 null;epNumber 未知时取第 1 条。 + */ +export function pickDandanEpisode( + episodes: RawDandanEpisode[], + epNumber: number | null, +): { episodeId: number; episodeTitle: string | null } | null { + const parsed: { + episodeId: number; + episodeTitle: string | null; + episodeNumber: string | null; + }[] = []; + for (const e of episodes) { + const id = + typeof e.episodeId === "number" + ? e.episodeId + : Number.parseInt(String(e.episodeId ?? ""), 10); + if (Number.isFinite(id)) { + parsed.push({ + episodeId: id, + episodeTitle: e.episodeTitle ?? null, + episodeNumber: e.episodeNumber ?? null, + }); + } + } + if (parsed.length === 0) return null; + if (epNumber != null) { + const exact = parsed.find((e) => { + const n = Number.parseInt(String(e.episodeNumber ?? ""), 10); + return Number.isFinite(n) && n === epNumber; + }); + if (exact) return { episodeId: exact.episodeId, episodeTitle: exact.episodeTitle }; + const byIndex = parsed[epNumber - 1]; + if (byIndex) return { episodeId: byIndex.episodeId, episodeTitle: byIndex.episodeTitle }; + return null; + } + const first = parsed[0]; + return first ? { episodeId: first.episodeId, episodeTitle: first.episodeTitle } : null; +} + +/** Bangumi 绑定 → 弹弹play 剧集(免 hash 桥 /api/v2/bangumi/bgmtv/{subjectId});失败/无数据返回 null */ +async function bridgeDandanEpisode( + bangumiId: number, + epNumber: number | null, +): Promise<{ episodeId: number; episodeTitle: string | null } | null> { + const res = await openFetchJson(`/api/v2/bangumi/bgmtv/${bangumiId}`); + if (!res.ok) return null; + const body = res.json as { bangumi?: { episodes?: RawDandanEpisode[] } }; + return pickDandanEpisode(body.bangumi?.episodes ?? [], epNumber); +} + function apiBase(): string { return process.env["BANGUMI_API_BASE"] ?? "https://api.bgm.tv"; } @@ -52,47 +171,170 @@ export async function bangumiSearch(q: string): Promise<BangumiSearchHit[]> { id: item.id, name: item.name, nameCn: item.nameCn ?? item.name, - image: item.images?.medium ?? item.images?.large ?? "", + image: item.images?.large ?? item.images?.medium ?? "", date: item.date ?? null, summary: item.summary ?? "", })); } +async function fetchSubjectMeta(bangumiId: number): Promise<{ + title: string | null; + posterUrl: string | null; +}> { + try { + const res = await fetch(`${apiBase()}/subject/${bangumiId}`, { + headers: { "User-Agent": "dandanplay-web/0.1" }, + }); + if (!res.ok) return { title: null, posterUrl: null }; + const subject = (await res.json()) as { + name?: string; + name_cn?: string; + images?: { medium?: string; large?: string }; + }; + return { + title: subject.name_cn || subject.name || null, + posterUrl: subject.images?.large ?? subject.images?.medium ?? null, + }; + } catch { + return { title: null, posterUrl: null }; + } +} + export const scrapeService = { search: bangumiSearch, - async bind(userId: string, mediaItemId: string, bangumiId: number): Promise<{ success: true }> { + /** + * 弹弹play 文件识别(刮削 + 弹幕身份):hash match 优先; + * 未中且有 Bangumi 绑定时走 bgmtv 桥按集数兜底。写 dandanplayEpisodeId + 元数据。 + */ + async matchDandanplay(userId: string, mediaItemId: string): Promise<DanmakuMatchOutput> { + const row = await mediaItemDao.getByIdForUser(mediaItemId, userId); + if (!row) throw new TRPCError({ code: "NOT_FOUND", message: "媒体不存在" }); + if (!hasOpenCredentials()) { + return { + ok: false, + matched: false, + message: + "未配置开放弹幕网络:服务端需设置 OPEN_DANMAKU_APP_ID / OPEN_DANMAKU_APP_SECRET", + }; + } + + const hash = await remoteFileHash(userId, mediaItemId); + const outcome = await openMatch(matchFileName(row.path), row.size, hash); + if (!outcome.ok) { + return { + ok: false, + matched: false, + message: outcome.errorMessage ?? "弹弹play 识别失败", + }; + } + const best = outcome.candidates[0]; + if (best) { + await mediaItemDao.updateScrape(mediaItemId, userId, { + dandanplayEpisodeId: best.episodeId, + matchedHash: hash, + scrapeStatus: "ok", + scrapedAt: new Date(), + posterUrl: best.imageUrl ?? row.posterUrl, + title: best.animeTitle ?? row.title, + epNumber: row.epNumber ?? parseEpisodeFromFilename(row.rawName), + }); + return { + ok: true, + matched: true, + result: { + episodeId: best.episodeId, + animeId: best.animeId, + animeTitle: best.animeTitle, + episodeTitle: best.episodeTitle, + imageUrl: best.imageUrl, + }, + }; + } + + if (row.bangumiId != null) { + const bridged = await bridgeDandanEpisode( + row.bangumiId, + row.epNumber ?? parseEpisodeFromFilename(row.rawName), + ); + if (bridged) { + await mediaItemDao.updateScrape(mediaItemId, userId, { + dandanplayEpisodeId: bridged.episodeId, + matchedHash: hash, + scrapeStatus: row.scrapeStatus, + scrapedAt: new Date(), + }); + return { + ok: true, + matched: true, + message: "已通过 Bangumi 绑定桥接到弹弹play 剧集", + result: { + episodeId: bridged.episodeId, + animeId: null, + animeTitle: row.title, + episodeTitle: bridged.episodeTitle, + imageUrl: row.posterUrl, + }, + }; + } + } + return { ok: true, matched: false, message: "弹弹play 未识别到该文件" }; + }, + + /** 手动绑定:指定作品(可覆盖集数),弹弹play 式「用户选定对应关系」 */ + async bind( + userId: string, + mediaItemId: string, + bangumiId: number, + epNumber?: number | undefined, + ): Promise<{ success: true }> { const row = await mediaItemDao.getByIdForUser(mediaItemId, userId); if (!row) throw new Error("媒体不存在"); - let title = row.title; - let posterUrl: string | null = row.posterUrl; - try { - const url = `${apiBase()}/subject/${bangumiId}`; - const res = await fetch(url, { headers: { "User-Agent": "dandanplay-web/0.1" } }); - if (res.ok) { - const subject = (await res.json()) as { - name?: string; - name_cn?: string; - images?: { medium?: string; large?: string }; - }; - title = subject.name_cn || subject.name || title; - posterUrl = subject.images?.medium ?? subject.images?.large ?? posterUrl; - } - } catch { - // 绑定 id 仍成功,海报可后补 - } + const meta = await fetchSubjectMeta(bangumiId); await mediaItemDao.updateScrape(mediaItemId, userId, { bangumiId, scrapeStatus: "ok", scrapedAt: new Date(), - posterUrl, - title, - epNumber: row.epNumber, + posterUrl: meta.posterUrl ?? row.posterUrl, + title: meta.title ?? row.title, + epNumber: epNumber ?? parseEpisodeFromFilename(row.rawName) ?? row.epNumber, }); return { success: true }; }, - /** 扫描后自动刮削:命中绑第一条,否则 unmatched */ + /** 整夹指定作品:前缀下所有条目绑同一 bangumiId,集数从文件名解析 */ + async bindPath( + userId: string, + mountId: string, + path: string, + bangumiId: number, + ): Promise<BatchResult> { + const rows = await mediaItemDao.listByPathPrefix(userId, mountId, path); + if (rows.length === 0) { + return { total: 0, bound: 0, skipped: 0, message: "该路径下暂无已入库媒体" }; + } + const meta = await fetchSubjectMeta(bangumiId); + let bound = 0; + for (const row of rows) { + await mediaItemDao.updateScrape(row.id, userId, { + bangumiId, + scrapeStatus: "ok", + scrapedAt: new Date(), + posterUrl: meta.posterUrl ?? row.posterUrl, + title: meta.title ?? row.title, + epNumber: parseEpisodeFromFilename(row.rawName) ?? row.epNumber, + }); + bound += 1; + } + return { + total: rows.length, + bound, + skipped: 0, + message: `已将 ${bound} 个条目指定为该作品`, + }; + }, + + /** 扫描后自动刮削:择优候选,低置信留 unmatched */ async autoScrape(userId: string, mediaItemId: string): Promise<void> { const row = await mediaItemDao.getByIdForUser(mediaItemId, userId); if (!row || row.scrapeStatus === "ok") return; @@ -115,8 +357,17 @@ export const scrapeService = { }); return; } - const hit = hits[0]; - if (!hit) { + // 候选打分择优(弹弹play:返回候选列表择优,而非盲取第一条) + let best: BangumiSearchHit | null = null; + let bestScore = 0; + for (const hit of hits) { + const score = scoreHit(q, hit); + if (score > bestScore) { + bestScore = score; + best = hit; + } + } + if (!best || bestScore < AUTO_BIND_THRESHOLD) { await mediaItemDao.updateScrape(mediaItemId, userId, { scrapeStatus: "unmatched", scrapedAt: new Date(), @@ -124,12 +375,34 @@ export const scrapeService = { return; } await mediaItemDao.updateScrape(mediaItemId, userId, { - bangumiId: hit.id, + bangumiId: best.id, scrapeStatus: "ok", scrapedAt: new Date(), - posterUrl: hit.image || null, - title: hit.nameCn || hit.name, + posterUrl: best.image || null, + title: best.nameCn || best.name, epNumber: parseEpisodeFromFilename(row.rawName), }); }, + + /** 文件夹级批量重刮(仅处理未 ok 的条目) */ + async rescrapePath(userId: string, mountId: string, path: string): Promise<BatchResult> { + const rows = await mediaItemDao.listByPathPrefix(userId, mountId, path); + let bound = 0; + let skipped = 0; + for (const row of rows) { + if (row.scrapeStatus === "ok" && row.bangumiId != null) { + skipped += 1; + continue; + } + await this.autoScrape(userId, row.id); + const after = await mediaItemDao.getByIdForUser(row.id, userId); + if (after?.scrapeStatus === "ok") bound += 1; + } + return { + total: rows.length, + bound, + skipped, + message: `批量刮削完成:成功 ${bound},跳过已绑定 ${skipped}`, + }; + }, }; diff --git a/packages/trpc/src/services/stream.service.ts b/packages/trpc/src/services/stream.service.ts index 3e5e9f2..1a0e97a 100644 --- a/packages/trpc/src/services/stream.service.ts +++ b/packages/trpc/src/services/stream.service.ts @@ -1,6 +1,6 @@ import { mediaItemDao, mountDao } from "@app/dao"; import { decryptSecret } from "./secret.js"; -import { createWebdav } from "./webdav-client.js"; +import { createWebdav, joinWebdavPath } from "./webdav-client.js"; export type RemoteFileStream = { status: number; @@ -8,6 +8,16 @@ export type RemoteFileStream = { body: ReadableStream<Uint8Array> | null; }; +/** 流打开失败的业务错误:status 供 HTTP 路由映射(404 媒体不存在 / 410 挂载失效 / 502 上游失败) */ +export class StreamSourceError extends Error { + readonly status: number; + constructor(message: string, status: number) { + super(message); + this.name = "StreamSourceError"; + this.status = status; + } +} + const MIME_BY_EXT: Record<string, string> = { mp4: "video/mp4", m4v: "video/mp4", @@ -59,16 +69,16 @@ export async function openRemoteFileStream( rangeHeader: string | null, ): Promise<RemoteFileStream> { const item = await mediaItemDao.getByIdForUser(mediaItemId, userId); - if (!item?.mountId) throw new Error("媒体或挂载不存在"); + if (!item?.mountId) throw new StreamSourceError("媒体不存在", 404); const mount = await mountDao.getByIdForUser(item.mountId, userId); - if (!mount) throw new Error("挂载不存在"); + if (!mount) throw new StreamSourceError("挂载已失效,请在挂载管理中重新添加", 410); const client = createWebdav({ baseUrl: mount.baseUrl, username: mount.username ?? undefined, password: mount.secretEnc ? decryptSecret(mount.secretEnc) : undefined, }); - const abs = mount.rootPath.replace(/\/+$/, "") + item.path; + const abs = joinWebdavPath(mount.rootPath || "/", item.path); const headers = new Headers(); headers.set("Content-Type", resolveContentType(item.mime, item.path)); @@ -98,13 +108,31 @@ export async function openRemoteFileStream( headers.set("Content-Length", String(item.size)); } - // createReadStream 同步返回 ReadableLike,range 选项嵌套在 { range } 内 - const nodeStream = - item.size > 0 - ? client.createReadStream(abs, { range: { start, end } }) - : client.createReadStream(abs); - // ReadableLike 类型未声明 pause/resume,但实现是 Node Readable(data 监听后进入 flowing 模式) - const src = nodeStream as typeof nodeStream & { pause(): void; resume(): void }; + // 先 await 远端响应再回 206:避免 createReadStream 异步失败时已经发出成功状态码 + const remoteHeaders: Record<string, string> = {}; + if (status === 206) { + remoteHeaders["Range"] = `bytes=${start}-${end}`; + } + let remote: unknown; + try { + remote = await client.customRequest(abs, { + method: "GET", + headers: remoteHeaders as never, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + throw new StreamSourceError(`片源读取失败:${msg.slice(0, 120)}`, 502); + } + // @buttercup/fetch 返回 Node PassThrough(ReadableLike,带 pause/resume) + const src = (remote as { body?: unknown }).body as { + on(ev: "data", cb: (chunk: Buffer | string) => void): void; + on(ev: "end", cb: () => void): void; + on(ev: "error", cb: (err: unknown) => void): void; + pause(): void; + resume(): void; + destroy(): void; + } | null; + if (!src) throw new StreamSourceError("远端未返回响应体", 502); // pull 驱动 + desiredSize 背压:队列满时 pause,pull(消费者取走)时 resume const body = new ReadableStream<Uint8Array>({ diff --git a/packages/trpc/src/services/user.service.ts b/packages/trpc/src/services/user.service.ts index 77b4562..bde5a38 100644 --- a/packages/trpc/src/services/user.service.ts +++ b/packages/trpc/src/services/user.service.ts @@ -15,13 +15,15 @@ export interface GetUserInput { id: string; } export interface CreateUserInput { - name: string; - email: string; + username: string; password: string; + name?: string | undefined; + email?: string | undefined; } // exactOptionalPropertyTypes: optional fields must accept undefined export interface UpdateUserInput { id: string; + username?: string | undefined; name?: string | undefined; email?: string | undefined; } @@ -30,42 +32,64 @@ export interface DeleteUserInput { } export type UserListOutput = { - users: UserRow[]; + users: PublicUser[]; total: number; limit: number; offset: number; }; +/** 对外输出的用户:永不包含 passwordHash(鉴权在 authService 直连 dao)。 */ +export type PublicUser = Omit<UserRow, "passwordHash">; + +function toPublicUser(row: UserRow): PublicUser { + return { + id: row.id, + username: row.username, + name: row.name, + email: row.email, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + // Service export const userService = { async list(input: ListUsersInput): Promise<UserListOutput> { const { rows, total } = await userDao.list({ limit: input.limit, offset: input.offset }); return { - users: rows, + users: rows.map(toPublicUser), total, limit: input.limit, offset: input.offset, }; }, - async get(input: GetUserInput): Promise<UserRow | null> { - return userDao.getById(input.id); + async get(input: GetUserInput): Promise<PublicUser | null> { + const row = await userDao.getById(input.id); + return row ? toPublicUser(row) : null; }, - async create(input: CreateUserInput): Promise<UserRow> { - return userDao.create({ - name: input.name, - email: input.email, + async create(input: CreateUserInput): Promise<PublicUser> { + const username = input.username.toLowerCase(); + const existing = await userDao.getByUsername(username); + if (existing) throw new Error("该用户名已被占用"); + const row = await userDao.create({ + username, + name: input.name?.trim() || username, + email: input.email ?? null, passwordHash: hashPassword(input.password), }); + return toPublicUser(row); }, - async update(input: UpdateUserInput): Promise<UserRow | null> { - const { id, name, email } = input; + async update(input: UpdateUserInput): Promise<PublicUser | null> { + const { id, username, name, email } = input; const data: Partial<Omit<UserRow, "id" | "createdAt" | "updatedAt">> = {}; + if (username !== undefined) data.username = username; if (name !== undefined) data.name = name; if (email !== undefined) data.email = email; - return userDao.update(id, data); + const row = await userDao.update(id, data); + return row ? toPublicUser(row) : null; }, async delete(input: DeleteUserInput): Promise<{ success: boolean }> { diff --git a/packages/trpc/src/services/webdav-client.ts b/packages/trpc/src/services/webdav-client.ts index fde49fd..9339930 100644 --- a/packages/trpc/src/services/webdav-client.ts +++ b/packages/trpc/src/services/webdav-client.ts @@ -35,6 +35,10 @@ export function joinWebdavPath(root: string, rel: string): string { const r = root.replace(/\/+$/, "") || ""; const p = rel.replace(/^\/+/, ""); if (!r || r === "/") return `/${p}`; + // rel 已是相对 WebDAV 根的绝对路径时不再拼接,避免 root+abs 双重前缀 404 + if (p === r.replace(/^\//, "") || p.startsWith(`${r.replace(/^\//, "")}/`)) { + return `/${p}`; + } return `${r}/${p}`; } diff --git a/packages/trpc/vite.config.ts b/packages/trpc/vite.config.ts index 0b5a23c..283d5fa 100644 --- a/packages/trpc/vite.config.ts +++ b/packages/trpc/vite.config.ts @@ -20,6 +20,10 @@ export default defineConfig({ "@app/types", "@trpc/server", "drizzle-orm", + // webdav 的 createReadStream 依赖 Node stream;打成 bundle 会把 + // `import Stream from "stream"` 掏成空模块,导致 PassThrough is not a constructor + "webdav", + "stream", /node:.*/, ], output: { diff --git a/packages/types/AGENTS.md b/packages/types/AGENTS.md index 097475a..7bc6203 100644 --- a/packages/types/AGENTS.md +++ b/packages/types/AGENTS.md @@ -4,23 +4,29 @@ 对外/跨层**输入校验与共享类型**的唯一源。只放 Zod schema 与纯类型,不依赖数据库、不跑 IO。 -- 入参 schema:`userSchemas`(create / update / get / list / delete)、`authSchemas` -- 输出类型:`User`、`UserListOutput` 等(手写 DTO,供前端与 service 对齐) +- 账户域:`userSchemas`(create / update / get / list / delete)、`authSchemas` +- 媒体域:`mountSchemas`、`librarySchemas`、`playbackSchemas`、`scrapeSchemas`、`danmakuSchemas` +- 共享工具:`media-fs.ts` 的 `VIDEO_EXTENSIONS` / `isVideoFilename` +- 输出类型:`User`、`LibraryGroup`、`SeriesOutput`、`ContinueItem`、`BatchResult` 等(手写 DTO,供前端与 service 对齐) ## 结构 ``` src/ - schemas.ts # Zod schema + 输出类型(业务入口) - index.ts # export * from "./schemas.js" + schemas.ts # 账户域 Zod schema + 输出类型 + media-schemas.ts # 媒体域 Zod schema + 输出类型 + media-fs.ts # 视频扩展名常量与判断(纯函数) + *.test.ts # schema 单测 + index.ts # export * 全量重导出 ``` ## 修改指南 -- 新实体:在 `schemas.ts` 增加 `xxxSchemas` 对象(按 create/update/get/list/delete 分组),并在 `index.ts` 重导出。 +- 新实体:在对应 `*schemas.ts` 增加 `xxxSchemas` 对象(按 create/update/get/list/delete 分组),并在 `index.ts` 重导出。 - **schema 必须被 `@app/trpc` 的 procedure 直接使用**,不要在 router 里再写一份校验。 -- 输出类型若与 Drizzle 行结构一致,可从 `@app/models` 的 `$inferSelect` 派生;若 API 形状与表不一致(如 `UserListOutput`),在此手写 DTO。 -- 错误消息用中文(与现有 `userSchemas` 一致)。 +- 输出类型若与 Drizzle 行结构一致,可从 `@app/models` 的 `$inferSelect` 派生;若 API 形状与表不一致(如 `LibraryGroupPage`),在此手写 DTO。 +- 错误消息用中文(与现有 schema 一致)。 +- schema 变更后补/改 `*.test.ts`,保证 zh 文案与可选字段行为不回退。 ## 约束 diff --git a/packages/types/src/media-schemas.ts b/packages/types/src/media-schemas.ts index 90b3516..9b650f9 100644 --- a/packages/types/src/media-schemas.ts +++ b/packages/types/src/media-schemas.ts @@ -39,15 +39,73 @@ export const librarySchemas = { list: z.object({ limit: z.number().min(1).max(100).default(24), offset: z.number().min(0).default(0), + mountId: id.optional(), scrapeStatus: z.enum(["ok", "unmatched", "failed", "pending"]).optional(), q: z.string().max(200).optional(), }), - scan: z.object({ mountId: id }), + /** 海报墙:指定挂载下已刮削条目按作品(bangumiId)分组的分页摘要 */ + groups: z.object({ + mountId: id, + q: z.string().max(200).optional(), + limit: z.number().int().min(1).max(100).default(24), + offset: z.number().int().min(0).default(0), + }), + scan: z.object({ + mountId: id, + /** 限定子目录;缺省扫整个挂载 */ + path: z.string().max(2000).optional(), + }), get: z.object({ id }), ensurePath: z.object({ mountId: id, path: z.string().min(1).max(2000), }), + /** 同作品(bangumiId)剧集列表;库内详情传 mountId 只看该挂载,播放页不传保持跨挂载导轨 */ + series: z.object({ + bangumiId: z.number().int().positive(), + mountId: id.optional(), + }), +}; + +export type LibraryGroup = { + bangumiId: number; + title: string; + posterUrl: string | null; + episodeCount: number; + /** ISO 8601;HTTP JSON 序列化后为字符串 */ + updatedAt: string; +}; + +/** 海报墙分页输出(分组摘要不含剧集,详情按需走 librarySeries) */ +export type LibraryGroupPage = { + total: number; + groups: LibraryGroup[]; +}; + +/** 同作品剧集精简项(播放页上下集导轨) */ +export type SeriesEpisode = { + id: string; + title: string; + epNumber: number | null; + rawName: string; +}; + +export type SeriesOutput = { + bangumiId: number; + title: string; + posterUrl: string | null; + episodes: SeriesEpisode[]; +}; + +/** 「继续观看」条目 */ +export type ContinueItem = { + mediaItemId: string; + title: string; + posterUrl: string | null; + positionMs: number; + durationMs: number; + /** ISO 8601 */ + updatedAt: string; }; export const playbackSchemas = { @@ -57,6 +115,10 @@ export const playbackSchemas = { durationMs: z.number().min(0), }), get: z.object({ mediaItemId: id }), + /** 未看完的最近条目,供「继续观看」闭环入口 */ + continueList: z.object({ + limit: z.number().int().min(1).max(20).default(6), + }), }; export const scrapeSchemas = { @@ -66,13 +128,28 @@ export const scrapeSchemas = { bind: z.object({ mediaItemId: id, bangumiId: z.number().int().positive(), + /** 手动指定集数;不传则保留/从文件名解析 */ + epNumber: z.number().int().min(0).max(9999).optional(), + }), + /** 整夹指定作品:path 前缀下所有条目绑同一 bangumiId,集数从文件名解析 */ + bindPath: z.object({ + mountId: id, + path: z.string().min(1).max(2000), + bangumiId: z.number().int().positive(), }), rescrape: z.object({ mediaItemId: id }), + /** 文件夹级批量重刮 */ + rescrapePath: z.object({ + mountId: id, + path: z.string().min(1).max(2000), + }), }; export const danmakuSchemas = { /** 开放网络:按媒体文件取弹幕 */ fetch: z.object({ mediaItemId: id }), + /** 手动弹弹play 文件识别(match),命中后写刮削元数据 + 弹幕身份 */ + match: z.object({ mediaItemId: id }), /** 本地 XML:仅登记导入元数据(正文在客户端喂给播放器) */ importMeta: z.object({ mediaItemId: id, @@ -89,9 +166,21 @@ export const danmakuSchemas = { export type MountCreateInput = z.infer<typeof mountSchemas.create>; export type MountListDirInput = z.infer<typeof mountSchemas.listDir>; export type LibraryListInput = z.infer<typeof librarySchemas.list>; +export type LibraryGroupsInput = z.infer<typeof librarySchemas.groups>; export type LibraryEnsurePathInput = z.infer<typeof librarySchemas.ensurePath>; +export type LibrarySeriesInput = z.infer<typeof librarySchemas.series>; export type PlaybackReportInput = z.infer<typeof playbackSchemas.report>; +export type PlaybackContinueInput = z.infer<typeof playbackSchemas.continueList>; export type ScrapeSearchInput = z.infer<typeof scrapeSchemas.search>; +export type ScrapeBindInput = z.infer<typeof scrapeSchemas.bind>; +export type ScrapeBindPathInput = z.infer<typeof scrapeSchemas.bindPath>; +export type ScrapeRescrapePathInput = z.infer<typeof scrapeSchemas.rescrapePath>; +export type BatchResult = { + total: number; + bound: number; + skipped: number; + message: string; +}; export type DanmakuFetchOutput = { ok: boolean; source: "open-network" | "cache" | "none"; @@ -99,3 +188,27 @@ export type DanmakuFetchOutput = { xml: string; message?: string; }; + +/** 弹弹play match 命中的剧集(MatchResultV2 的刮削子集) */ +export type DandanMatchResult = { + episodeId: number; + animeId: number | null; + animeTitle: string | null; + episodeTitle: string | null; + imageUrl: string | null; +}; + +export type DanmakuMatchOutput = { + ok: boolean; + matched: boolean; + message?: string; + result?: DandanMatchResult; +}; + +export type DanmakuSettingsOutput = { + enabled: boolean; + opacity: number; + density: number; + /** 服务端是否已配置开放弹幕网络凭据(未配置时弹幕获取恒为空) */ + openNetworkConfigured: boolean; +}; diff --git a/packages/types/src/schemas.ts b/packages/types/src/schemas.ts index 0304d1f..8af1c45 100644 --- a/packages/types/src/schemas.ts +++ b/packages/types/src/schemas.ts @@ -3,22 +3,34 @@ * `@app/trpc` 的 procedure 直接复用这里的 schema,禁止在 router 再写一份校验。 * 输出类型若与表结构不一致(如分页包装),在此手写 DTO 而不是从 models 派生。 * - * 本文件只保留脚手架基线(user / auth)。新业务实体按同样模式在此追加 `xxxSchemas`。 + * user / auth 为账号基线。新业务实体按同样模式在此追加 `xxxSchemas`。 */ import { z } from "zod"; +// ─── Shared field schemas ─────────────────────────────────────────────────── +/** 登录名:3–32 位字母/数字/_/-,统一小写存储。 */ +export const usernameSchema = z + .string() + .min(3, "用户名至少 3 位") + .max(32, "用户名不能超过 32 位") + .regex(/^[a-zA-Z0-9_-]+$/, "用户名仅支持字母、数字、下划线和短横线"); + +const passwordSchema = z.string().min(8, "密码至少 8 位").max(128, "密码不能超过 128 位"); + // ─── User ─────────────────────────────────────────────────────────────────── export const userSchemas = { /** 创建用户 */ create: z.object({ - name: z.string().min(1, "名称不能为空").max(100), - email: z.string().email("邮箱格式不正确"), - password: z.string().min(8, "密码至少 8 位").max(128), + username: usernameSchema, + name: z.string().min(1).max(100).optional(), + email: z.string().email("邮箱格式不正确").optional(), + password: passwordSchema, }), /** 更新用户 */ update: z.object({ id: z.string(), + username: usernameSchema.optional(), name: z.string().min(1).max(100).optional(), email: z.string().email().optional(), }), @@ -41,19 +53,18 @@ export const userSchemas = { }; // ─── Auth ─────────────────────────────────────────────────────────────────── -const passwordSchema = z.string().min(8, "密码至少 8 位").max(128, "密码不能超过 128 位"); - export const authSchemas = { /** 注册(成功后自动建会话) */ register: z.object({ - name: z.string().min(1, "名称不能为空").max(100), - email: z.string().email("邮箱格式不正确"), + username: usernameSchema, + name: z.string().min(1).max(100).optional(), + email: z.string().email("邮箱格式不正确").optional(), password: passwordSchema, }), - /** 登录 */ + /** 登录(默认:用户名 + 密码) */ login: z.object({ - email: z.string().email("邮箱格式不正确"), + username: usernameSchema, password: z.string().min(1, "密码不能为空").max(128), }), @@ -69,7 +80,7 @@ export const authSchemas = { newPassword: passwordSchema, }), - /** 申请重置密码 */ + /** 申请重置密码(按邮箱) */ requestPasswordReset: z.object({ email: z.string().email("邮箱格式不正确"), }), @@ -88,8 +99,9 @@ export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // ─── Shared output types ──────────────────────────────────────────────────── export type User = { id: string; + username: string; name: string; - email: string; + email: string | null; createdAt: Date | null; updatedAt: Date | null; }; @@ -97,8 +109,9 @@ export type User = { /** 对外暴露的登录用户(不含 passwordHash)。 */ export type AuthUser = { id: string; + username: string; name: string; - email: string; + email: string | null; }; export type AuthSessionOutput = { diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..a2dcb1c --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,40 @@ +import { defineConfig, devices } from "@playwright/test"; + +// 本机 http_proxy 会把 localhost 探活/请求也带进代理(502),E2E 内网请求必须直连 +for (const key of [ + "http_proxy", + "https_proxy", + "HTTP_PROXY", + "HTTPS_PROXY", + "all_proxy", + "ALL_PROXY", +]) { + delete process.env[key]; +} +process.env.NO_PROXY = "*"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: [["list"]], + use: { + baseURL: "http://localhost:4321", + trace: "on-first-retry", + locale: "en-US", + acceptLanguage: "en-US", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: { + command: "yarn dev:web", + url: "http://localhost:4321", + reuseExistingServer: !process.env.CI, + timeout: 300_000, + }, +}); diff --git a/yarn.lock b/yarn.lock index bf46035..b9dacdc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,6 +12,203 @@ __metadata: languageName: node linkType: hard +"@algolia/abtesting@npm:1.25.0": + version: 1.25.0 + resolution: "@algolia/abtesting@npm:1.25.0" + dependencies: + "@algolia/client-common": "npm:5.59.0" + "@algolia/requester-browser-xhr": "npm:5.59.0" + "@algolia/requester-fetch": "npm:5.59.0" + "@algolia/requester-node-http": "npm:5.59.0" + checksum: 10c0/2a15c2875fc0db008d30b3714162a6c896427bc5c9dae490c4978460a4458dcf85e4834b82c08adc526fcca2c558a33e74e458ecce73adc85f0c33702444c195 + languageName: node + linkType: hard + +"@algolia/autocomplete-core@npm:1.17.7": + version: 1.17.7 + resolution: "@algolia/autocomplete-core@npm:1.17.7" + dependencies: + "@algolia/autocomplete-plugin-algolia-insights": "npm:1.17.7" + "@algolia/autocomplete-shared": "npm:1.17.7" + checksum: 10c0/603e0f0157eed71a8fabfba2d14ca846e399dc4e10bc300eb2f018529f9ac68f689193f582b6e97828e01bb150c045bb7d251aa40950a058a191dc560895ed98 + languageName: node + linkType: hard + +"@algolia/autocomplete-plugin-algolia-insights@npm:1.17.7": + version: 1.17.7 + resolution: "@algolia/autocomplete-plugin-algolia-insights@npm:1.17.7" + dependencies: + "@algolia/autocomplete-shared": "npm:1.17.7" + peerDependencies: + search-insights: ">= 1 < 3" + checksum: 10c0/4f0f6b87ca76ea2fb45bfaa8a14c206d5bead60962b80bad10fd26928a37835d61a7420cbfd07cc2f1eb027b23b2e14f5796acfc35a74a9f51653367ee95e506 + languageName: node + linkType: hard + +"@algolia/autocomplete-preset-algolia@npm:1.17.7": + version: 1.17.7 + resolution: "@algolia/autocomplete-preset-algolia@npm:1.17.7" + dependencies: + "@algolia/autocomplete-shared": "npm:1.17.7" + peerDependencies: + "@algolia/client-search": ">= 4.9.1 < 6" + algoliasearch: ">= 4.9.1 < 6" + checksum: 10c0/eb20746cbba532f8ade62fb48b7d2b6e9b2e0b5acc33bc80071630d3da724d78242de9c06cf838bef402ce2a912e86ab018bd2f6728ecb0f981a22c65bbbb2cb + languageName: node + linkType: hard + +"@algolia/autocomplete-shared@npm:1.17.7": + version: 1.17.7 + resolution: "@algolia/autocomplete-shared@npm:1.17.7" + peerDependencies: + "@algolia/client-search": ">= 4.9.1 < 6" + algoliasearch: ">= 4.9.1 < 6" + checksum: 10c0/9eb0c3ab57c7bae5b9c1d4c5c58dfdab56d1f4591f7488bd3d1dfd372eb8fa03416c97e247a3fcd581cda075eaea8b973dcfa306a8085c67d71f14513e3f5c5b + languageName: node + linkType: hard + +"@algolia/client-abtesting@npm:5.59.0": + version: 5.59.0 + resolution: "@algolia/client-abtesting@npm:5.59.0" + dependencies: + "@algolia/client-common": "npm:5.59.0" + "@algolia/requester-browser-xhr": "npm:5.59.0" + "@algolia/requester-fetch": "npm:5.59.0" + "@algolia/requester-node-http": "npm:5.59.0" + checksum: 10c0/b12b00148365c9e2b058949859f2516cb5c39aeb8a2ab163257a181543b439bf5656ca9f42555f623b71cc61246332f760a4943113650e412e46114c19804cd7 + languageName: node + linkType: hard + +"@algolia/client-analytics@npm:5.59.0": + version: 5.59.0 + resolution: "@algolia/client-analytics@npm:5.59.0" + dependencies: + "@algolia/client-common": "npm:5.59.0" + "@algolia/requester-browser-xhr": "npm:5.59.0" + "@algolia/requester-fetch": "npm:5.59.0" + "@algolia/requester-node-http": "npm:5.59.0" + checksum: 10c0/b5c227609021957daf6873287d8dde129bcceae89c6c1b228d0202c0b771ad1b94de590d622dfbfda23fc24bdb9c7fd25321d94a3ea295fb6560d222cba49ee3 + languageName: node + linkType: hard + +"@algolia/client-common@npm:5.59.0": + version: 5.59.0 + resolution: "@algolia/client-common@npm:5.59.0" + checksum: 10c0/b0313f671631384ab7abad5d08651aa478a457ecf0f7faad063a13703b7fe89f985036dd21dec7ecc4342351c87b6eab78f1a20bb3124fb7b31efa7bfe42d7da + languageName: node + linkType: hard + +"@algolia/client-insights@npm:5.59.0": + version: 5.59.0 + resolution: "@algolia/client-insights@npm:5.59.0" + dependencies: + "@algolia/client-common": "npm:5.59.0" + "@algolia/requester-browser-xhr": "npm:5.59.0" + "@algolia/requester-fetch": "npm:5.59.0" + "@algolia/requester-node-http": "npm:5.59.0" + checksum: 10c0/a6b21b817c32ca6390ff3e5729d648e38d8c296efdadda8f8cba67decc896089ae7c29cba79b90919e2acc1831118417bf00506ad497be42195a98af82422023 + languageName: node + linkType: hard + +"@algolia/client-personalization@npm:5.59.0": + version: 5.59.0 + resolution: "@algolia/client-personalization@npm:5.59.0" + dependencies: + "@algolia/client-common": "npm:5.59.0" + "@algolia/requester-browser-xhr": "npm:5.59.0" + "@algolia/requester-fetch": "npm:5.59.0" + "@algolia/requester-node-http": "npm:5.59.0" + checksum: 10c0/18445be9c0fa4a41fda7ffa300566e036d62247ede009637498b2969f5f7e521a6af4507d4ef2738e23c6932b3361f63c59dea6d9ec72d4afc423ab078a7b475 + languageName: node + linkType: hard + +"@algolia/client-query-suggestions@npm:5.59.0": + version: 5.59.0 + resolution: "@algolia/client-query-suggestions@npm:5.59.0" + dependencies: + "@algolia/client-common": "npm:5.59.0" + "@algolia/requester-browser-xhr": "npm:5.59.0" + "@algolia/requester-fetch": "npm:5.59.0" + "@algolia/requester-node-http": "npm:5.59.0" + checksum: 10c0/58f94d70a707e556fe47e6a9fb666411028ee0da35895fb1a1051402e4d574900fa7aa0fce667f4ad6235cbd513c15d54a27562ecfa5997d04373cde78ba88b2 + languageName: node + linkType: hard + +"@algolia/client-search@npm:5.59.0": + version: 5.59.0 + resolution: "@algolia/client-search@npm:5.59.0" + dependencies: + "@algolia/client-common": "npm:5.59.0" + "@algolia/requester-browser-xhr": "npm:5.59.0" + "@algolia/requester-fetch": "npm:5.59.0" + "@algolia/requester-node-http": "npm:5.59.0" + checksum: 10c0/c0558ee45485855a580936924a35a458fa5e1a51c93c0d3b75486ab057937cef649414068e20cb22bdab5948f4aa8f74131b83ce889a29b5c10e330d92c8556d + languageName: node + linkType: hard + +"@algolia/ingestion@npm:1.59.0": + version: 1.59.0 + resolution: "@algolia/ingestion@npm:1.59.0" + dependencies: + "@algolia/client-common": "npm:5.59.0" + "@algolia/requester-browser-xhr": "npm:5.59.0" + "@algolia/requester-fetch": "npm:5.59.0" + "@algolia/requester-node-http": "npm:5.59.0" + checksum: 10c0/58933854f9f1bf08dc3ef5d9fef4fa5fc491510b2945cd6d8dc960a9461826e424c525f3195267c8293404017ad0c8edce79c9f86f27f275d4384ec2113cf939 + languageName: node + linkType: hard + +"@algolia/monitoring@npm:1.59.0": + version: 1.59.0 + resolution: "@algolia/monitoring@npm:1.59.0" + dependencies: + "@algolia/client-common": "npm:5.59.0" + "@algolia/requester-browser-xhr": "npm:5.59.0" + "@algolia/requester-fetch": "npm:5.59.0" + "@algolia/requester-node-http": "npm:5.59.0" + checksum: 10c0/76457d58caa2f3b65ed893b18bdd05b6ad7984ed861832d3fedc57b672d9298e03433075a6507893030910985d88caec85da0db58b6aa65fe0682d245baba9e9 + languageName: node + linkType: hard + +"@algolia/recommend@npm:5.59.0": + version: 5.59.0 + resolution: "@algolia/recommend@npm:5.59.0" + dependencies: + "@algolia/client-common": "npm:5.59.0" + "@algolia/requester-browser-xhr": "npm:5.59.0" + "@algolia/requester-fetch": "npm:5.59.0" + "@algolia/requester-node-http": "npm:5.59.0" + checksum: 10c0/d23bc7ee05ece908570ee6f3e311df0b813764c76eac59397da4c97a9b006e418ab098e5dd22f2c2073be40fe90f2b7ed976e17426402790e890e673443da109 + languageName: node + linkType: hard + +"@algolia/requester-browser-xhr@npm:5.59.0": + version: 5.59.0 + resolution: "@algolia/requester-browser-xhr@npm:5.59.0" + dependencies: + "@algolia/client-common": "npm:5.59.0" + checksum: 10c0/1040fd5dddf56639672f99c8c167fbf6290fa94cd3495813618d5edbe1cfe720124d2a378482bc9bde5d429faea15a8c0e6416c1b5e151d32089725ebe19ea89 + languageName: node + linkType: hard + +"@algolia/requester-fetch@npm:5.59.0": + version: 5.59.0 + resolution: "@algolia/requester-fetch@npm:5.59.0" + dependencies: + "@algolia/client-common": "npm:5.59.0" + checksum: 10c0/be778d2dbbfd762b2433747c50322e402fcabc204f979ffaf833aea2d884c4ff63409fbe98df3014b9a4e0769354824ca74e6d0c24444692621c5b2fba91a069 + languageName: node + linkType: hard + +"@algolia/requester-node-http@npm:5.59.0": + version: 5.59.0 + resolution: "@algolia/requester-node-http@npm:5.59.0" + dependencies: + "@algolia/client-common": "npm:5.59.0" + checksum: 10c0/c8bbcf4f4a7908bd4257267cf6a26cfdd2c4484676ed08ad6deb1fe2dd010116dc6ed850737b66180c4eeee7d4d2dcd79c1cdec236e16ce446158f585d554986 + languageName: node + linkType: hard + "@app/adsense@workspace:packages/adsense": version: 0.0.0-use.local resolution: "@app/adsense@workspace:packages/adsense" @@ -45,6 +242,7 @@ __metadata: "@types/node": "npm:^22.15.29" drizzle-kit: "npm:^0.30.2" drizzle-orm: "npm:^0.45.3" + tsx: "npm:^4.19.4" typescript: "npm:^7.0.2" languageName: unknown linkType: soft @@ -55,6 +253,16 @@ __metadata: languageName: unknown linkType: soft +"@app/docs@workspace:app/docs": + version: 0.0.0-use.local + resolution: "@app/docs@workspace:app/docs" + dependencies: + "@biomejs/biome": "npm:^2.1.1" + typescript: "npm:^7.0.2" + vitepress: "npm:^1.6.4" + languageName: unknown + linkType: soft + "@app/i18n@workspace:*, @app/i18n@workspace:packages/i18n": version: 0.0.0-use.local resolution: "@app/i18n@workspace:packages/i18n" @@ -176,6 +384,7 @@ __metadata: "@types/react": "npm:^19.1.4" "@types/react-dom": "npm:^19.1.5" artplayer: "npm:^5.2.2" + artplayer-plugin-danmuku: "npm:^5.3.0" astro: "npm:^7.3.3" gsap: "npm:^3.15.0" lenis: "npm:^1.3.26" @@ -1070,6 +1279,49 @@ __metadata: languageName: node linkType: hard +"@docsearch/css@npm:3.8.2": + version: 3.8.2 + resolution: "@docsearch/css@npm:3.8.2" + checksum: 10c0/32f86b7b344834885a4a0b1a317d3fb568bafb2ceab5b4733c2d99ebd13d85899035fcb2680c940876c96d0d9f7b5db84b5be3a4d5ca41f0807775cc31991cff + languageName: node + linkType: hard + +"@docsearch/js@npm:3.8.2": + version: 3.8.2 + resolution: "@docsearch/js@npm:3.8.2" + dependencies: + "@docsearch/react": "npm:3.8.2" + preact: "npm:^10.0.0" + checksum: 10c0/8e3f9c91287f7b7f258d41fbffc5c5c567e2554dcd8127566a771c05112efcf69b99bb6ad14e86ce4f8e506218e5ddb377f94d9a2d336e648b66a18a650c9df2 + languageName: node + linkType: hard + +"@docsearch/react@npm:3.8.2": + version: 3.8.2 + resolution: "@docsearch/react@npm:3.8.2" + dependencies: + "@algolia/autocomplete-core": "npm:1.17.7" + "@algolia/autocomplete-preset-algolia": "npm:1.17.7" + "@docsearch/css": "npm:3.8.2" + algoliasearch: "npm:^5.14.2" + peerDependencies: + "@types/react": ">= 16.8.0 < 19.0.0" + react: ">= 16.8.0 < 19.0.0" + react-dom: ">= 16.8.0 < 19.0.0" + search-insights: ">= 1 < 3" + peerDependenciesMeta: + "@types/react": + optional: true + react: + optional: true + react-dom: + optional: true + search-insights: + optional: true + checksum: 10c0/f54916d478abb2e8b797ad19b4c549c162aa04a9cdc8eca5e92d31722404ddafa64669922008bd1e723ea9d2cd8f3eee7f8ed22c224118ae961640503bd90be1 + languageName: node + linkType: hard + "@drizzle-team/brocli@npm:^0.10.2": version: 0.10.2 resolution: "@drizzle-team/brocli@npm:0.10.2" @@ -1218,6 +1470,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/aix-ppc64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/aix-ppc64@npm:0.21.5" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/aix-ppc64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/aix-ppc64@npm:0.25.12" @@ -1246,6 +1505,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/android-arm64@npm:0.21.5" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/android-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/android-arm64@npm:0.25.12" @@ -1274,6 +1540,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/android-arm@npm:0.21.5" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@esbuild/android-arm@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/android-arm@npm:0.25.12" @@ -1302,6 +1575,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/android-x64@npm:0.21.5" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + "@esbuild/android-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/android-x64@npm:0.25.12" @@ -1330,6 +1610,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/darwin-arm64@npm:0.21.5" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/darwin-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/darwin-arm64@npm:0.25.12" @@ -1358,6 +1645,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/darwin-x64@npm:0.21.5" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@esbuild/darwin-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/darwin-x64@npm:0.25.12" @@ -1386,6 +1680,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/freebsd-arm64@npm:0.21.5" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/freebsd-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/freebsd-arm64@npm:0.25.12" @@ -1414,6 +1715,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/freebsd-x64@npm:0.21.5" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/freebsd-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/freebsd-x64@npm:0.25.12" @@ -1442,6 +1750,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-arm64@npm:0.21.5" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/linux-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-arm64@npm:0.25.12" @@ -1470,6 +1785,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-arm@npm:0.21.5" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@esbuild/linux-arm@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-arm@npm:0.25.12" @@ -1498,6 +1820,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ia32@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-ia32@npm:0.21.5" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/linux-ia32@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-ia32@npm:0.25.12" @@ -1526,6 +1855,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-loong64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-loong64@npm:0.21.5" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + "@esbuild/linux-loong64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-loong64@npm:0.25.12" @@ -1554,6 +1890,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-mips64el@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-mips64el@npm:0.21.5" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + "@esbuild/linux-mips64el@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-mips64el@npm:0.25.12" @@ -1582,6 +1925,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ppc64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-ppc64@npm:0.21.5" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/linux-ppc64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-ppc64@npm:0.25.12" @@ -1610,6 +1960,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-riscv64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-riscv64@npm:0.21.5" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + "@esbuild/linux-riscv64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-riscv64@npm:0.25.12" @@ -1638,6 +1995,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-s390x@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-s390x@npm:0.21.5" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + "@esbuild/linux-s390x@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-s390x@npm:0.25.12" @@ -1666,6 +2030,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-x64@npm:0.21.5" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + "@esbuild/linux-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-x64@npm:0.25.12" @@ -1708,6 +2079,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/netbsd-x64@npm:0.21.5" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/netbsd-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/netbsd-x64@npm:0.25.12" @@ -1750,6 +2128,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/openbsd-x64@npm:0.21.5" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openbsd-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/openbsd-x64@npm:0.25.12" @@ -1792,6 +2177,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/sunos-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/sunos-x64@npm:0.21.5" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + "@esbuild/sunos-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/sunos-x64@npm:0.25.12" @@ -1820,6 +2212,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/win32-arm64@npm:0.21.5" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/win32-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/win32-arm64@npm:0.25.12" @@ -1848,6 +2247,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-ia32@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/win32-ia32@npm:0.21.5" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/win32-ia32@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/win32-ia32@npm:0.25.12" @@ -1876,6 +2282,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/win32-x64@npm:0.21.5" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@esbuild/win32-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/win32-x64@npm:0.25.12" @@ -1928,6 +2341,22 @@ __metadata: languageName: node linkType: hard +"@iconify-json/simple-icons@npm:^1.2.21": + version: 1.2.97 + resolution: "@iconify-json/simple-icons@npm:1.2.97" + dependencies: + "@iconify/types": "npm:*" + checksum: 10c0/38d39364126c30de1d9d265b918c572e8a692a3fe897024229b467d2fb3f773acbfd6163c6c91e4443c1b761b1ea61e9eba42aee55a1f10ff2191d93404f0a15 + languageName: node + linkType: hard + +"@iconify/types@npm:*": + version: 2.0.0 + resolution: "@iconify/types@npm:2.0.0" + checksum: 10c0/65a3be43500c7ccacf360e136d00e1717f050b7b91da644e94370256ac66f582d59212bdb30d00788aab4fc078262e91c95b805d1808d654b72f6d2072a7e4b2 + languageName: node + linkType: hard + "@img/colour@npm:^1.1.0": version: 1.1.0 resolution: "@img/colour@npm:1.1.0" @@ -2430,6 +2859,17 @@ __metadata: languageName: node linkType: hard +"@playwright/test@npm:^1.63.0": + version: 1.63.0 + resolution: "@playwright/test@npm:1.63.0" + dependencies: + playwright: "npm:1.63.0" + bin: + playwright: cli.js + checksum: 10c0/795cd06c2eb6cb6142e72792f4df8f97665aa8c551c16278a7f2b3f31664e0cff07ed3c066490da55daf2fe51bc18e9c1ec6cba05b2c37387a0c49ad63017db8 + languageName: node + linkType: hard + "@radix-ui/primitive@npm:1.1.7": version: 1.1.7 resolution: "@radix-ui/primitive@npm:1.1.7" @@ -3092,6 +3532,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-android-arm-eabi@npm:4.63.5": + version: 4.63.5 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.63.5" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@rollup/rollup-android-arm64@npm:4.63.4": version: 4.63.4 resolution: "@rollup/rollup-android-arm64@npm:4.63.4" @@ -3099,6 +3546,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-android-arm64@npm:4.63.5": + version: 4.63.5 + resolution: "@rollup/rollup-android-arm64@npm:4.63.5" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-darwin-arm64@npm:4.63.4": version: 4.63.4 resolution: "@rollup/rollup-darwin-arm64@npm:4.63.4" @@ -3106,6 +3560,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-darwin-arm64@npm:4.63.5": + version: 4.63.5 + resolution: "@rollup/rollup-darwin-arm64@npm:4.63.5" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-darwin-x64@npm:4.63.4": version: 4.63.4 resolution: "@rollup/rollup-darwin-x64@npm:4.63.4" @@ -3113,6 +3574,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-darwin-x64@npm:4.63.5": + version: 4.63.5 + resolution: "@rollup/rollup-darwin-x64@npm:4.63.5" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@rollup/rollup-freebsd-arm64@npm:4.63.4": version: 4.63.4 resolution: "@rollup/rollup-freebsd-arm64@npm:4.63.4" @@ -3120,6 +3588,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-freebsd-arm64@npm:4.63.5": + version: 4.63.5 + resolution: "@rollup/rollup-freebsd-arm64@npm:4.63.5" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-freebsd-x64@npm:4.63.4": version: 4.63.4 resolution: "@rollup/rollup-freebsd-x64@npm:4.63.4" @@ -3127,6 +3602,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-freebsd-x64@npm:4.63.5": + version: 4.63.5 + resolution: "@rollup/rollup-freebsd-x64@npm:4.63.5" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@rollup/rollup-linux-arm-gnueabihf@npm:4.63.4": version: 4.63.4 resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.63.4" @@ -3134,6 +3616,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm-gnueabihf@npm:4.63.5": + version: 4.63.5 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.63.5" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-arm-musleabihf@npm:4.63.4": version: 4.63.4 resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.63.4" @@ -3141,6 +3630,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm-musleabihf@npm:4.63.5": + version: 4.63.5 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.63.5" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-arm64-gnu@npm:4.63.4": version: 4.63.4 resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.63.4" @@ -3148,6 +3644,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm64-gnu@npm:4.63.5": + version: 4.63.5 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.63.5" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-arm64-musl@npm:4.63.4": version: 4.63.4 resolution: "@rollup/rollup-linux-arm64-musl@npm:4.63.4" @@ -3155,6 +3658,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm64-musl@npm:4.63.5": + version: 4.63.5 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.63.5" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-loong64-gnu@npm:4.63.4": version: 4.63.4 resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.63.4" @@ -3162,6 +3672,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-loong64-gnu@npm:4.63.5": + version: 4.63.5 + resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.63.5" + conditions: os=linux & cpu=loong64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-loong64-musl@npm:4.63.4": version: 4.63.4 resolution: "@rollup/rollup-linux-loong64-musl@npm:4.63.4" @@ -3169,6 +3686,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-loong64-musl@npm:4.63.5": + version: 4.63.5 + resolution: "@rollup/rollup-linux-loong64-musl@npm:4.63.5" + conditions: os=linux & cpu=loong64 & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-ppc64-gnu@npm:4.63.4": version: 4.63.4 resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.63.4" @@ -3176,6 +3700,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-ppc64-gnu@npm:4.63.5": + version: 4.63.5 + resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.63.5" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-ppc64-musl@npm:4.63.4": version: 4.63.4 resolution: "@rollup/rollup-linux-ppc64-musl@npm:4.63.4" @@ -3183,6 +3714,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-ppc64-musl@npm:4.63.5": + version: 4.63.5 + resolution: "@rollup/rollup-linux-ppc64-musl@npm:4.63.5" + conditions: os=linux & cpu=ppc64 & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-riscv64-gnu@npm:4.63.4": version: 4.63.4 resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.63.4" @@ -3190,6 +3728,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-riscv64-gnu@npm:4.63.5": + version: 4.63.5 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.63.5" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-riscv64-musl@npm:4.63.4": version: 4.63.4 resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.63.4" @@ -3197,6 +3742,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-riscv64-musl@npm:4.63.5": + version: 4.63.5 + resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.63.5" + conditions: os=linux & cpu=riscv64 & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-s390x-gnu@npm:4.63.4": version: 4.63.4 resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.63.4" @@ -3204,6 +3756,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-s390x-gnu@npm:4.63.5": + version: 4.63.5 + resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.63.5" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-x64-gnu@npm:4.63.4": version: 4.63.4 resolution: "@rollup/rollup-linux-x64-gnu@npm:4.63.4" @@ -3211,6 +3770,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-x64-gnu@npm:4.63.5": + version: 4.63.5 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.63.5" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-x64-musl@npm:4.63.4": version: 4.63.4 resolution: "@rollup/rollup-linux-x64-musl@npm:4.63.4" @@ -3218,6 +3784,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-x64-musl@npm:4.63.5": + version: 4.63.5 + resolution: "@rollup/rollup-linux-x64-musl@npm:4.63.5" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-openbsd-x64@npm:4.63.4": version: 4.63.4 resolution: "@rollup/rollup-openbsd-x64@npm:4.63.4" @@ -3225,6 +3798,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-openbsd-x64@npm:4.63.5": + version: 4.63.5 + resolution: "@rollup/rollup-openbsd-x64@npm:4.63.5" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + "@rollup/rollup-openharmony-arm64@npm:4.63.4": version: 4.63.4 resolution: "@rollup/rollup-openharmony-arm64@npm:4.63.4" @@ -3232,6 +3812,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-openharmony-arm64@npm:4.63.5": + version: 4.63.5 + resolution: "@rollup/rollup-openharmony-arm64@npm:4.63.5" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-win32-arm64-msvc@npm:4.63.4": version: 4.63.4 resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.63.4" @@ -3239,6 +3826,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-arm64-msvc@npm:4.63.5": + version: 4.63.5 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.63.5" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-win32-ia32-msvc@npm:4.63.4": version: 4.63.4 resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.63.4" @@ -3246,6 +3840,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-ia32-msvc@npm:4.63.5": + version: 4.63.5 + resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.63.5" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@rollup/rollup-win32-x64-gnu@npm:4.63.4": version: 4.63.4 resolution: "@rollup/rollup-win32-x64-gnu@npm:4.63.4" @@ -3253,6 +3854,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-x64-gnu@npm:4.63.5": + version: 4.63.5 + resolution: "@rollup/rollup-win32-x64-gnu@npm:4.63.5" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@rollup/rollup-win32-x64-msvc@npm:4.63.4": version: 4.63.4 resolution: "@rollup/rollup-win32-x64-msvc@npm:4.63.4" @@ -3260,6 +3868,27 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-x64-msvc@npm:4.63.5": + version: 4.63.5 + resolution: "@rollup/rollup-win32-x64-msvc@npm:4.63.5" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@shikijs/core@npm:2.5.0, @shikijs/core@npm:^2.1.0": + version: 2.5.0 + resolution: "@shikijs/core@npm:2.5.0" + dependencies: + "@shikijs/engine-javascript": "npm:2.5.0" + "@shikijs/engine-oniguruma": "npm:2.5.0" + "@shikijs/types": "npm:2.5.0" + "@shikijs/vscode-textmate": "npm:^10.0.2" + "@types/hast": "npm:^3.0.4" + hast-util-to-html: "npm:^9.0.4" + checksum: 10c0/8851e1db14cf09ec4c6a9ad656d67b807a53e27d0face69a234ad810b3e8198e3fa19a32e9f062a49fd000487c88d8096675bb8fbfaecc64dc99125694f2d7e1 + languageName: node + linkType: hard + "@shikijs/core@npm:4.4.3": version: 4.4.3 resolution: "@shikijs/core@npm:4.4.3" @@ -3273,6 +3902,17 @@ __metadata: languageName: node linkType: hard +"@shikijs/engine-javascript@npm:2.5.0": + version: 2.5.0 + resolution: "@shikijs/engine-javascript@npm:2.5.0" + dependencies: + "@shikijs/types": "npm:2.5.0" + "@shikijs/vscode-textmate": "npm:^10.0.2" + oniguruma-to-es: "npm:^3.1.0" + checksum: 10c0/5cc43e7e0c25eb3c56e0ce48ea55e4c8f09e235e4148f5c852febcd0569f74b6e3c91887d2e5bb079821fb2f13412e551d20491b516e2ad7ae675b613a727093 + languageName: node + linkType: hard + "@shikijs/engine-javascript@npm:4.4.3": version: 4.4.3 resolution: "@shikijs/engine-javascript@npm:4.4.3" @@ -3284,6 +3924,16 @@ __metadata: languageName: node linkType: hard +"@shikijs/engine-oniguruma@npm:2.5.0": + version: 2.5.0 + resolution: "@shikijs/engine-oniguruma@npm:2.5.0" + dependencies: + "@shikijs/types": "npm:2.5.0" + "@shikijs/vscode-textmate": "npm:^10.0.2" + checksum: 10c0/7c1ae4cf0196b51b8a1da9496ca2ba5fbd9d1765629e312922804507b07d794208ff6562ece555518c641c99f190ec2c22202edddd5d78c7983a163677883dfe + languageName: node + linkType: hard + "@shikijs/engine-oniguruma@npm:4.4.3": version: 4.4.3 resolution: "@shikijs/engine-oniguruma@npm:4.4.3" @@ -3294,6 +3944,15 @@ __metadata: languageName: node linkType: hard +"@shikijs/langs@npm:2.5.0": + version: 2.5.0 + resolution: "@shikijs/langs@npm:2.5.0" + dependencies: + "@shikijs/types": "npm:2.5.0" + checksum: 10c0/bf8007f33aa9e8d5009054e9ab00fa608605bd203090bd3a648316cb632a63b6b99009a674198f386bb0647e1ce8f0d91adac3324999a8697eb6eafc12a6f0ed + languageName: node + linkType: hard + "@shikijs/langs@npm:4.4.3": version: 4.4.3 resolution: "@shikijs/langs@npm:4.4.3" @@ -3314,6 +3973,15 @@ __metadata: languageName: node linkType: hard +"@shikijs/themes@npm:2.5.0": + version: 2.5.0 + resolution: "@shikijs/themes@npm:2.5.0" + dependencies: + "@shikijs/types": "npm:2.5.0" + checksum: 10c0/73e38847138a2cb4a034af15279f30f4637a4f95217cb1d4212ea708f00cb68cb6c9ed0067a41c4d67b1b6a4cec1f71034aae22c2626a24629f2e13f7363428d + languageName: node + linkType: hard + "@shikijs/themes@npm:4.4.3": version: 4.4.3 resolution: "@shikijs/themes@npm:4.4.3" @@ -3323,6 +3991,26 @@ __metadata: languageName: node linkType: hard +"@shikijs/transformers@npm:^2.1.0": + version: 2.5.0 + resolution: "@shikijs/transformers@npm:2.5.0" + dependencies: + "@shikijs/core": "npm:2.5.0" + "@shikijs/types": "npm:2.5.0" + checksum: 10c0/2a9c91215ed14182ef4cdc248ee490cef56bc220a0dd50e551be77a704c51360aea9104bdc4a25f6c54f999ebd4e1bc123075735e930daf3e2c82393373197a7 + languageName: node + linkType: hard + +"@shikijs/types@npm:2.5.0, @shikijs/types@npm:^2.1.0": + version: 2.5.0 + resolution: "@shikijs/types@npm:2.5.0" + dependencies: + "@shikijs/vscode-textmate": "npm:^10.0.2" + "@types/hast": "npm:^3.0.4" + checksum: 10c0/71e75351ac550d079d220235d40d4aff7f81c2ac8cd7d846cf16cd10db4d59bee982e097d87b4709a8ef5993add96a6841a0f6a42caebc300cfbe83191028f5d + languageName: node + linkType: hard + "@shikijs/types@npm:4.4.3": version: 4.4.3 resolution: "@shikijs/types@npm:4.4.3" @@ -3859,6 +4547,23 @@ __metadata: languageName: node linkType: hard +"@types/linkify-it@npm:^5": + version: 5.0.0 + resolution: "@types/linkify-it@npm:5.0.0" + checksum: 10c0/7bbbf45b9dde17bf3f184fee585aef0e7342f6954f0377a24e4ff42ab5a85d5b806aaa5c8d16e2faf2a6b87b2d94467a196b7d2b85c9c7de2f0eaac5487aaab8 + languageName: node + linkType: hard + +"@types/markdown-it@npm:^14.1.2": + version: 14.2.0 + resolution: "@types/markdown-it@npm:14.2.0" + dependencies: + "@types/linkify-it": "npm:^5" + "@types/mdurl": "npm:^2" + checksum: 10c0/60ff1a0495ef303310744566342c49b4b3e76fbdca82c5ec28d88731fc934b0c63335d25bca6133903c877f2c41c59cdfddefb3626c6ee0780b9573ddf6aefe4 + languageName: node + linkType: hard + "@types/mdast@npm:^4.0.0, @types/mdast@npm:^4.0.4": version: 4.0.4 resolution: "@types/mdast@npm:4.0.4" @@ -3868,6 +4573,13 @@ __metadata: languageName: node linkType: hard +"@types/mdurl@npm:^2": + version: 2.0.0 + resolution: "@types/mdurl@npm:2.0.0" + checksum: 10c0/cde7bb571630ed1ceb3b92a28f7b59890bb38b8f34cd35326e2df43eebfc74985e6aa6fd4184e307393bad8a9e0783a519a3f9d13c8e03788c0f98e5ec869c5e + languageName: node + linkType: hard + "@types/mdx@npm:^2.0.0": version: 2.0.14 resolution: "@types/mdx@npm:2.0.14" @@ -3959,6 +4671,13 @@ __metadata: languageName: node linkType: hard +"@types/web-bluetooth@npm:^0.0.21": + version: 0.0.21 + resolution: "@types/web-bluetooth@npm:0.0.21" + checksum: 10c0/5f47c5ec452ca31e6a9b44c8aaa54b66c4f57d4aca00166c45902a3883139580e14b254b774172ff982ba4458cc444b5aa59bb4573c2fd34562cfa599721f8e4 + languageName: node + linkType: hard + "@types/ws@npm:^8.5.4": version: 8.18.1 resolution: "@types/ws@npm:8.18.1" @@ -4175,6 +4894,16 @@ __metadata: languageName: node linkType: hard +"@vitejs/plugin-vue@npm:^5.2.1": + version: 5.2.4 + resolution: "@vitejs/plugin-vue@npm:5.2.4" + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + checksum: 10c0/9559224f178daf35e3a665410d09089b0ce7c0402981f8757481c24c22f29df377f96cc6161d92f74d16c37c6e32ac19fea99086f75338ad6ceb9b5ee8375509 + languageName: node + linkType: hard + "@vitest/expect@npm:3.2.4": version: 3.2.4 resolution: "@vitest/expect@npm:3.2.4" @@ -4354,6 +5083,215 @@ __metadata: languageName: node linkType: hard +"@vue/compiler-core@npm:3.5.43": + version: 3.5.43 + resolution: "@vue/compiler-core@npm:3.5.43" + dependencies: + "@babel/parser": "npm:^7.29.8" + "@vue/shared": "npm:3.5.43" + entities: "npm:^7.0.1" + estree-walker: "npm:^2.0.2" + source-map-js: "npm:^1.2.1" + checksum: 10c0/a6aa1a3e852a759322afa62ba22dae4f56fdcb275e1bc78c9588d65b9b53d24fd093965eb985b92c1ecd50a7b6dd9b7beddd8b3aebf207509b0ffc8844417137 + languageName: node + linkType: hard + +"@vue/compiler-dom@npm:3.5.43": + version: 3.5.43 + resolution: "@vue/compiler-dom@npm:3.5.43" + dependencies: + "@vue/compiler-core": "npm:3.5.43" + "@vue/shared": "npm:3.5.43" + checksum: 10c0/7176866486b51b18315b298bba613ddee38da1dbae7491b8bd87843951e26f347fab2027892d431655147253c011f5f3c152d7f7acca968c6eb51b7b446adabc + languageName: node + linkType: hard + +"@vue/compiler-sfc@npm:3.5.43": + version: 3.5.43 + resolution: "@vue/compiler-sfc@npm:3.5.43" + dependencies: + "@babel/parser": "npm:^7.29.8" + "@vue/compiler-core": "npm:3.5.43" + "@vue/compiler-dom": "npm:3.5.43" + "@vue/compiler-ssr": "npm:3.5.43" + "@vue/shared": "npm:3.5.43" + estree-walker: "npm:^2.0.2" + magic-string: "npm:^0.30.21" + postcss: "npm:^8.5.28" + source-map-js: "npm:^1.2.1" + checksum: 10c0/fe07bd2d0519f1350b55514480fd5f0a2d2f963a1fff0ef8cea56c79dc2ae9def2584815ab1e5c2e70d9cb8450147dadc90533e5106d21283d867d62461969e2 + languageName: node + linkType: hard + +"@vue/compiler-ssr@npm:3.5.43": + version: 3.5.43 + resolution: "@vue/compiler-ssr@npm:3.5.43" + dependencies: + "@vue/compiler-dom": "npm:3.5.43" + "@vue/shared": "npm:3.5.43" + checksum: 10c0/d1a47d99b5fcc13838a5049361f585798e9f50f2fbae709c5153463db3660c0c301efe7c849f4f1beb6e3e0dc18648d85ae1ea7c9d5654860c38296d56ab831e + languageName: node + linkType: hard + +"@vue/devtools-api@npm:^7.7.0": + version: 7.7.10 + resolution: "@vue/devtools-api@npm:7.7.10" + dependencies: + "@vue/devtools-kit": "npm:^7.7.10" + checksum: 10c0/4cb94a4bc47baacfc14c5d45b65cf97680af98b0e2fb5ef0a889138575dc2506ddd671ef219a333c4314e185d323e32e8dac87e9a71800822f58b4b3428cf337 + languageName: node + linkType: hard + +"@vue/devtools-kit@npm:^7.7.10": + version: 7.7.10 + resolution: "@vue/devtools-kit@npm:7.7.10" + dependencies: + "@vue/devtools-shared": "npm:^7.7.10" + birpc: "npm:^2.3.0" + hookable: "npm:^5.5.3" + mitt: "npm:^3.0.1" + perfect-debounce: "npm:^1.0.0" + speakingurl: "npm:^14.0.1" + superjson: "npm:^2.2.2" + checksum: 10c0/37f7948d34fbb6f78d2617c8cf66b9466fd81bf3745db3c83da87ddd99a4787e87a6c31dd35b6861db27efcd23abbb3dc2b2910fbfdda9f7a92d78c7cc18a987 + languageName: node + linkType: hard + +"@vue/devtools-shared@npm:^7.7.10": + version: 7.7.10 + resolution: "@vue/devtools-shared@npm:7.7.10" + dependencies: + rfdc: "npm:^1.4.1" + checksum: 10c0/553f2f01a5e1c622169ea59749b7271fd5105dc91db61b035031e342d153996b9d2211b6be8faccf29c4bb1d19136be1dfeafd3881e00ec360acaafe7ab749cc + languageName: node + linkType: hard + +"@vue/reactivity@npm:3.5.43": + version: 3.5.43 + resolution: "@vue/reactivity@npm:3.5.43" + dependencies: + "@vue/shared": "npm:3.5.43" + checksum: 10c0/30818da249137848a5a01c2eefbaaaba83abc2cb7c65d2521aa4ef6f1b1a677573dc063db95c5242829c7c1b287438cdeb343bd73cd7dce180de4d3234b988f0 + languageName: node + linkType: hard + +"@vue/runtime-core@npm:3.5.43": + version: 3.5.43 + resolution: "@vue/runtime-core@npm:3.5.43" + dependencies: + "@vue/reactivity": "npm:3.5.43" + "@vue/shared": "npm:3.5.43" + checksum: 10c0/f023832140f650994bc1cb9b3145ca2635e2661e6bccac40162f77103e3e9b6ae1f67e18fb9a32ac261888491c3429ef22e853775cac6a7ce0ef24f1fba90805 + languageName: node + linkType: hard + +"@vue/runtime-dom@npm:3.5.43": + version: 3.5.43 + resolution: "@vue/runtime-dom@npm:3.5.43" + dependencies: + "@vue/reactivity": "npm:3.5.43" + "@vue/runtime-core": "npm:3.5.43" + "@vue/shared": "npm:3.5.43" + csstype: "npm:^3.2.3" + checksum: 10c0/7c4844d19570e379e5a7b07f1b694f5f36d069d38293272b0e2011984bcde9f87844b3ac1325e67ed608bf278709a1b13a4c470c52746e83c0de60fd14427335 + languageName: node + linkType: hard + +"@vue/server-renderer@npm:3.5.43": + version: 3.5.43 + resolution: "@vue/server-renderer@npm:3.5.43" + dependencies: + "@vue/compiler-ssr": "npm:3.5.43" + "@vue/runtime-dom": "npm:3.5.43" + "@vue/shared": "npm:3.5.43" + checksum: 10c0/bc3fa8227db943987dae28c271c8ef7c09e42e0777480bca49d89446b51aa4d76c6c71b8169a48ccb2d7ade51e4add3bdee50106c7df73b17a65558a6d7d7f7d + languageName: node + linkType: hard + +"@vue/shared@npm:3.5.43, @vue/shared@npm:^3.5.13": + version: 3.5.43 + resolution: "@vue/shared@npm:3.5.43" + checksum: 10c0/2925261c79d49957523060a8e6c92e29a588301d22ee3f978d09d1aba881256a703285ae0108f7b50d76dd10b054ca31a0c1f1ed8696afbef7bf58e862782b4b + languageName: node + linkType: hard + +"@vueuse/core@npm:12.8.2, @vueuse/core@npm:^12.4.0": + version: 12.8.2 + resolution: "@vueuse/core@npm:12.8.2" + dependencies: + "@types/web-bluetooth": "npm:^0.0.21" + "@vueuse/metadata": "npm:12.8.2" + "@vueuse/shared": "npm:12.8.2" + vue: "npm:^3.5.13" + checksum: 10c0/79c74c7daa471bbf6d51bf83d3750105074d83f6628fcdf41828e9f87977de4a4744968f48ad2d13597e758baf1a0aa5fe4f348dbecf093c9437bd8fd9937b73 + languageName: node + linkType: hard + +"@vueuse/integrations@npm:^12.4.0": + version: 12.8.2 + resolution: "@vueuse/integrations@npm:12.8.2" + dependencies: + "@vueuse/core": "npm:12.8.2" + "@vueuse/shared": "npm:12.8.2" + vue: "npm:^3.5.13" + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 + universal-cookie: ^7 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + checksum: 10c0/f65423fb0cac6e892e0cc99ffea645588568fa5d40c5fc40123eb98fb43ef245c44bb69a172e9d45ff529a0bb429426f2221b02cd11b77e81d97b44584303339 + languageName: node + linkType: hard + +"@vueuse/metadata@npm:12.8.2": + version: 12.8.2 + resolution: "@vueuse/metadata@npm:12.8.2" + checksum: 10c0/95d352506608d5f8ae9c99ce494e7e55d864707d903f20401fecf378b08c8ed96631d0b8b38f6cd1d04dda69d9c8d3e4fa37e33caae10692805a9566e316e39c + languageName: node + linkType: hard + +"@vueuse/shared@npm:12.8.2": + version: 12.8.2 + resolution: "@vueuse/shared@npm:12.8.2" + dependencies: + vue: "npm:^3.5.13" + checksum: 10c0/43a04cf44e3377ee7666787e597d45ddf83880d3e1835000201b0c471198ce83ad45e4f4a0182d68781c0a23c84a904aab253e4c6279400a235e0246b613bad2 + languageName: node + linkType: hard + "abbrev@npm:^5.0.0": version: 5.0.0 resolution: "abbrev@npm:5.0.0" @@ -4403,6 +5341,28 @@ __metadata: languageName: node linkType: hard +"algoliasearch@npm:^5.14.2": + version: 5.59.0 + resolution: "algoliasearch@npm:5.59.0" + dependencies: + "@algolia/abtesting": "npm:1.25.0" + "@algolia/client-abtesting": "npm:5.59.0" + "@algolia/client-analytics": "npm:5.59.0" + "@algolia/client-common": "npm:5.59.0" + "@algolia/client-insights": "npm:5.59.0" + "@algolia/client-personalization": "npm:5.59.0" + "@algolia/client-query-suggestions": "npm:5.59.0" + "@algolia/client-search": "npm:5.59.0" + "@algolia/ingestion": "npm:1.59.0" + "@algolia/monitoring": "npm:1.59.0" + "@algolia/recommend": "npm:5.59.0" + "@algolia/requester-browser-xhr": "npm:5.59.0" + "@algolia/requester-fetch": "npm:5.59.0" + "@algolia/requester-node-http": "npm:5.59.0" + checksum: 10c0/d56558be095e3e8a7c3b0ecb5375167ea0e39eb345f877da6ad22baa242902ac5889baf2ba493975d88a885c3354c0ec8785653067fe6a2664cf23b735ffa24c + languageName: node + linkType: hard + "am-i-vibing@npm:^0.4.0": version: 0.4.0 resolution: "am-i-vibing@npm:0.4.0" @@ -4461,27 +5421,6 @@ __metadata: languageName: node linkType: hard -"app-template@workspace:.": - version: 0.0.0-use.local - resolution: "app-template@workspace:." - dependencies: - "@astrojs/check": "npm:^0.9.10" - "@biomejs/biome": "npm:^2.1.1" - "@commitlint/cli": "npm:^21.2.3" - "@commitlint/config-conventional": "npm:^21.2.3" - "@emnapi/runtime": "npm:^1.11.3" - "@storybook/addon-a11y": "npm:^9.1.10" - "@storybook/addon-docs": "npm:^9.1.10" - "@storybook/react": "npm:^9.1.10" - "@storybook/react-vite": "npm:^9.1.10" - "@typescript/typescript6": "npm:^6.0.2" - lefthook: "npm:^2.1.14" - storybook: "npm:^9.1.10" - turbo: "npm:^2.11.2" - typescript: "npm:^7.0.2" - languageName: unknown - linkType: soft - "arg@npm:^5.0.0": version: 5.0.2 resolution: "arg@npm:5.0.2" @@ -4519,6 +5458,13 @@ __metadata: languageName: node linkType: hard +"artplayer-plugin-danmuku@npm:^5.3.0": + version: 5.3.0 + resolution: "artplayer-plugin-danmuku@npm:5.3.0" + checksum: 10c0/fccbd67d56b2364072b9852dd032ce052800688b9523a72b0795872a1fc53c7a89d9f0aa05ed2c1bd9647a65ae682910471fb9488463abe810ad4754cc8a4dff + languageName: node + linkType: hard + "artplayer@npm:^5.2.2": version: 5.4.0 resolution: "artplayer@npm:5.4.0" @@ -4669,6 +5615,13 @@ __metadata: languageName: node linkType: hard +"birpc@npm:^2.3.0": + version: 2.9.0 + resolution: "birpc@npm:2.9.0" + checksum: 10c0/2462d0d67061f95bae213b0b9b323a6643ff749f7457a25242897c99e31355f1bd522c17f83ecf57506351e3e28b4e38c12a39b8beddee2dd0cbf78f9b9876ce + languageName: node + linkType: hard + "boolbase@npm:^1.0.0": version: 1.0.0 resolution: "boolbase@npm:1.0.0" @@ -4937,6 +5890,13 @@ __metadata: languageName: node linkType: hard +"copy-anything@npm:^4": + version: 4.1.1 + resolution: "copy-anything@npm:4.1.1" + checksum: 10c0/12127518f68018949ceb98535fee440303d9663db9e62ded045a93ec874410c1015c678e09ddb3a3457eeb09663fda01ba2db30a610fb12b618d263aa9eec464 + languageName: node + linkType: hard + "cosmiconfig-typescript-loader@npm:^6.1.0": version: 6.3.0 resolution: "cosmiconfig-typescript-loader@npm:6.3.0" @@ -5050,7 +6010,7 @@ __metadata: languageName: node linkType: hard -"csstype@npm:^3.2.2": +"csstype@npm:^3.2.2, csstype@npm:^3.2.3": version: 3.2.3 resolution: "csstype@npm:3.2.3" checksum: 10c0/cd29c51e70fa822f1cecd8641a1445bed7063697469d35633b516e60fe8c1bde04b08f6c5b6022136bb669b64c63d4173af54864510fbb4ee23281801841a3ce @@ -5376,6 +6336,13 @@ __metadata: languageName: node linkType: hard +"emoji-regex-xs@npm:^1.0.0": + version: 1.0.0 + resolution: "emoji-regex-xs@npm:1.0.0" + checksum: 10c0/1082de006991eb05a3324ef0efe1950c7cdf66efc01d4578de82b0d0d62add4e55e97695a8a7eeda826c305081562dc79b477ddf18d886da77f3ba08c4b940a0 + languageName: node + linkType: hard + "emoji-regex@npm:^10.3.0": version: 10.6.0 resolution: "emoji-regex@npm:10.6.0" @@ -5428,6 +6395,13 @@ __metadata: languageName: node linkType: hard +"entities@npm:^7.0.1": + version: 7.0.1 + resolution: "entities@npm:7.0.1" + checksum: 10c0/b4fb9937bb47ecb00aaaceb9db9cdd1cc0b0fb649c0e843d05cf5dbbd2e9d2df8f98721d8b1b286445689c72af7b54a7242fc2d63ef7c9739037a8c73363e7ca + languageName: node + linkType: hard + "env-paths@npm:^2.2.0, env-paths@npm:^2.2.1": version: 2.2.1 resolution: "env-paths@npm:2.2.1" @@ -5659,6 +6633,86 @@ __metadata: languageName: node linkType: hard +"esbuild@npm:^0.21.3": + version: 0.21.5 + resolution: "esbuild@npm:0.21.5" + dependencies: + "@esbuild/aix-ppc64": "npm:0.21.5" + "@esbuild/android-arm": "npm:0.21.5" + "@esbuild/android-arm64": "npm:0.21.5" + "@esbuild/android-x64": "npm:0.21.5" + "@esbuild/darwin-arm64": "npm:0.21.5" + "@esbuild/darwin-x64": "npm:0.21.5" + "@esbuild/freebsd-arm64": "npm:0.21.5" + "@esbuild/freebsd-x64": "npm:0.21.5" + "@esbuild/linux-arm": "npm:0.21.5" + "@esbuild/linux-arm64": "npm:0.21.5" + "@esbuild/linux-ia32": "npm:0.21.5" + "@esbuild/linux-loong64": "npm:0.21.5" + "@esbuild/linux-mips64el": "npm:0.21.5" + "@esbuild/linux-ppc64": "npm:0.21.5" + "@esbuild/linux-riscv64": "npm:0.21.5" + "@esbuild/linux-s390x": "npm:0.21.5" + "@esbuild/linux-x64": "npm:0.21.5" + "@esbuild/netbsd-x64": "npm:0.21.5" + "@esbuild/openbsd-x64": "npm:0.21.5" + "@esbuild/sunos-x64": "npm:0.21.5" + "@esbuild/win32-arm64": "npm:0.21.5" + "@esbuild/win32-ia32": "npm:0.21.5" + "@esbuild/win32-x64": "npm:0.21.5" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10c0/fa08508adf683c3f399e8a014a6382a6b65542213431e26206c0720e536b31c09b50798747c2a105a4bbba1d9767b8d3615a74c2f7bf1ddf6d836cd11eb672de + languageName: node + linkType: hard + "esbuild@npm:^0.27.0 || ^0.28.0, esbuild@npm:^0.28.0, esbuild@npm:~0.28.0": version: 0.28.2 resolution: "esbuild@npm:0.28.2" @@ -5907,6 +6961,28 @@ __metadata: languageName: node linkType: hard +"fanmu@workspace:.": + version: 0.0.0-use.local + resolution: "fanmu@workspace:." + dependencies: + "@astrojs/check": "npm:^0.9.10" + "@biomejs/biome": "npm:^2.1.1" + "@commitlint/cli": "npm:^21.2.3" + "@commitlint/config-conventional": "npm:^21.2.3" + "@emnapi/runtime": "npm:^1.11.3" + "@playwright/test": "npm:^1.63.0" + "@storybook/addon-a11y": "npm:^9.1.10" + "@storybook/addon-docs": "npm:^9.1.10" + "@storybook/react": "npm:^9.1.10" + "@storybook/react-vite": "npm:^9.1.10" + "@typescript/typescript6": "npm:^6.0.2" + lefthook: "npm:^2.1.14" + storybook: "npm:^9.1.10" + turbo: "npm:^2.11.2" + typescript: "npm:^7.0.2" + languageName: unknown + linkType: soft + "fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" @@ -6019,6 +7095,15 @@ __metadata: languageName: node linkType: hard +"focus-trap@npm:^7.6.4": + version: 7.8.0 + resolution: "focus-trap@npm:7.8.0" + dependencies: + tabbable: "npm:^6.4.0" + checksum: 10c0/b75f97c2896de569fc779f9b898b7c4a48b0605176b2a7a4b70a64bc2ae39a0b3c66fc7c8b2cd4e5d1e0a439b031e025a7cd1031d3a68ab652c0495bc14f070a + languageName: node + linkType: hard + "fontace@npm:~0.4.1": version: 0.4.1 resolution: "fontace@npm:0.4.1" @@ -6223,7 +7308,7 @@ __metadata: languageName: node linkType: hard -"hast-util-to-html@npm:^9.0.5": +"hast-util-to-html@npm:^9.0.4, hast-util-to-html@npm:^9.0.5": version: 9.0.5 resolution: "hast-util-to-html@npm:9.0.5" dependencies: @@ -6251,6 +7336,13 @@ __metadata: languageName: node linkType: hard +"hookable@npm:^5.5.3": + version: 5.5.3 + resolution: "hookable@npm:5.5.3" + checksum: 10c0/275f4cc84d27f8d48c5a5cd5685b6c0fea9291be9deea5bff0cfa72856ed566abde1dcd8cb1da0f9a70b4da3d7ec0d60dc3554c4edbba647058cc38816eced3d + languageName: node + linkType: hard + "hookable@npm:^6.1.1": version: 6.1.2 resolution: "hookable@npm:6.1.2" @@ -7045,6 +8137,13 @@ __metadata: languageName: node linkType: hard +"mark.js@npm:8.11.1": + version: 8.11.1 + resolution: "mark.js@npm:8.11.1" + checksum: 10c0/5e69e776db61abdd857b5cbb7070c8a3b1b0e5c12bf077fcd5a8c6f17b1f85ed65275aba5662b57136d1b9f82b54bb34d4ef4220f7703c9a7ab806ae1e208cff + languageName: node + linkType: hard + "md5@npm:^2.3.0": version: 2.3.0 resolution: "md5@npm:2.3.0" @@ -7243,6 +8342,13 @@ __metadata: languageName: node linkType: hard +"minisearch@npm:^7.1.1": + version: 7.2.0 + resolution: "minisearch@npm:7.2.0" + checksum: 10c0/64efaf30ead2acb19eb8be49c78352c527812dd2927a0981c9d666339d5d206d132b83038d2bfa756f5161cae5d98f15b07cc7e7b7be6b8278d35d2ecdc2628c + languageName: node + linkType: hard + "minizlib@npm:^3.1.0": version: 3.1.0 resolution: "minizlib@npm:3.1.0" @@ -7252,6 +8358,13 @@ __metadata: languageName: node linkType: hard +"mitt@npm:^3.0.1": + version: 3.0.1 + resolution: "mitt@npm:3.0.1" + checksum: 10c0/3ab4fdecf3be8c5255536faa07064d05caa3dd332bd318ff02e04621f7b3069ca1de9106cfe8e7ced675abfc2bec2ce4c4ef321c4a1bb1fb29df8ae090741913 + languageName: node + linkType: hard + "mrmime@npm:^2.0.1": version: 2.0.1 resolution: "mrmime@npm:2.0.1" @@ -7432,6 +8545,17 @@ __metadata: languageName: node linkType: hard +"oniguruma-to-es@npm:^3.1.0": + version: 3.1.1 + resolution: "oniguruma-to-es@npm:3.1.1" + dependencies: + emoji-regex-xs: "npm:^1.0.0" + regex: "npm:^6.0.1" + regex-recursion: "npm:^6.0.2" + checksum: 10c0/8eb8390076a396674bf8fe9fe6036fe4ea3316f9793d36fd7fe23dc443492ef52a06f5113408bec7d5ea5ad1dc1984623fcb998b2d2198e35ba2aed3ecb91676 + languageName: node + linkType: hard + "oniguruma-to-es@npm:^4.3.6": version: 4.3.6 resolution: "oniguruma-to-es@npm:4.3.6" @@ -7601,6 +8725,13 @@ __metadata: languageName: node linkType: hard +"perfect-debounce@npm:^1.0.0": + version: 1.0.0 + resolution: "perfect-debounce@npm:1.0.0" + checksum: 10c0/e2baac416cae046ef1b270812cf9ccfb0f91c04ea36ac7f5b00bc84cb7f41bdbba087c0ab21b4e02a7ef3a1f1f6db399f137cecec46868bd7d8d88c2a9ee431f + languageName: node + linkType: hard + "piccolore@npm:^0.1.3": version: 0.1.3 resolution: "piccolore@npm:0.1.3" @@ -7629,7 +8760,27 @@ __metadata: languageName: node linkType: hard -"postcss@npm:^8.5.28, postcss@npm:^8.5.3, postcss@npm:^8.5.6": +"playwright-core@npm:1.63.0": + version: 1.63.0 + resolution: "playwright-core@npm:1.63.0" + bin: + playwright-core: cli.js + checksum: 10c0/39c1ebae4029ca2e9371e00a5588d962038e24b6e0bdb7d0d27374dde04838f349b83ae0c8b3b648653d0935cba7fb9011f1201adaab9dd0db7801ad6c75e9aa + languageName: node + linkType: hard + +"playwright@npm:1.63.0": + version: 1.63.0 + resolution: "playwright@npm:1.63.0" + dependencies: + playwright-core: "npm:1.63.0" + bin: + playwright: cli.js + checksum: 10c0/d1fd048a89be27c9aba6757dc4f70eff4a5163b49f439ad691767be8b2a482327d0dd1928b4f73c8fe0d8b33aab2c9dc2670424ab60f112d9e98e91b94e3b090 + languageName: node + linkType: hard + +"postcss@npm:^8.4.43, postcss@npm:^8.5.28, postcss@npm:^8.5.3, postcss@npm:^8.5.6": version: 8.5.28 resolution: "postcss@npm:8.5.28" dependencies: @@ -7640,6 +8791,18 @@ __metadata: languageName: node linkType: hard +"preact@npm:^10.0.0": + version: 10.29.8 + resolution: "preact@npm:10.29.8" + peerDependencies: + preact-render-to-string: ">=5" + peerDependenciesMeta: + preact-render-to-string: + optional: true + checksum: 10c0/505460352139e0ce5b9c971b69d8d9f3b73ea0f316e3ff1c0b3db9a1abe206c9dff6ba385eb796253c38fcc08b5ade70cd8da3d9d6c9e9aec1a2fd59348253ae + languageName: node + linkType: hard + "prettier@npm:^3.8.1": version: 3.9.8 resolution: "prettier@npm:3.9.8" @@ -7861,7 +9024,7 @@ __metadata: languageName: node linkType: hard -"regex@npm:^6.1.0": +"regex@npm:^6.0.1, regex@npm:^6.1.0": version: 6.1.0 resolution: "regex@npm:6.1.0" dependencies: @@ -7958,6 +9121,13 @@ __metadata: languageName: node linkType: hard +"rfdc@npm:^1.4.1": + version: 1.4.1 + resolution: "rfdc@npm:1.4.1" + checksum: 10c0/4614e4292356cafade0b6031527eea9bc90f2372a22c012313be1dcc69a3b90c7338158b414539be863fa95bfcb2ddcd0587be696841af4e6679d85e62c060c7 + languageName: node + linkType: hard + "rolldown@npm:~1.2.6": version: 1.2.9 resolution: "rolldown@npm:1.2.9" @@ -8016,6 +9186,99 @@ __metadata: languageName: node linkType: hard +"rollup@npm:^4.20.0": + version: 4.63.5 + resolution: "rollup@npm:4.63.5" + dependencies: + "@napi-rs/lzma-linux-x64-gnu": "npm:1.5.1" + "@rollup/rollup-android-arm-eabi": "npm:4.63.5" + "@rollup/rollup-android-arm64": "npm:4.63.5" + "@rollup/rollup-darwin-arm64": "npm:4.63.5" + "@rollup/rollup-darwin-x64": "npm:4.63.5" + "@rollup/rollup-freebsd-arm64": "npm:4.63.5" + "@rollup/rollup-freebsd-x64": "npm:4.63.5" + "@rollup/rollup-linux-arm-gnueabihf": "npm:4.63.5" + "@rollup/rollup-linux-arm-musleabihf": "npm:4.63.5" + "@rollup/rollup-linux-arm64-gnu": "npm:4.63.5" + "@rollup/rollup-linux-arm64-musl": "npm:4.63.5" + "@rollup/rollup-linux-loong64-gnu": "npm:4.63.5" + "@rollup/rollup-linux-loong64-musl": "npm:4.63.5" + "@rollup/rollup-linux-ppc64-gnu": "npm:4.63.5" + "@rollup/rollup-linux-ppc64-musl": "npm:4.63.5" + "@rollup/rollup-linux-riscv64-gnu": "npm:4.63.5" + "@rollup/rollup-linux-riscv64-musl": "npm:4.63.5" + "@rollup/rollup-linux-s390x-gnu": "npm:4.63.5" + "@rollup/rollup-linux-x64-gnu": "npm:4.63.5" + "@rollup/rollup-linux-x64-musl": "npm:4.63.5" + "@rollup/rollup-openbsd-x64": "npm:4.63.5" + "@rollup/rollup-openharmony-arm64": "npm:4.63.5" + "@rollup/rollup-win32-arm64-msvc": "npm:4.63.5" + "@rollup/rollup-win32-ia32-msvc": "npm:4.63.5" + "@rollup/rollup-win32-x64-gnu": "npm:4.63.5" + "@rollup/rollup-win32-x64-msvc": "npm:4.63.5" + "@types/estree": "npm:1.0.9" + fsevents: "npm:~2.3.2" + dependenciesMeta: + "@napi-rs/lzma-linux-x64-gnu": + optional: true + "@rollup/rollup-android-arm-eabi": + optional: true + "@rollup/rollup-android-arm64": + optional: true + "@rollup/rollup-darwin-arm64": + optional: true + "@rollup/rollup-darwin-x64": + optional: true + "@rollup/rollup-freebsd-arm64": + optional: true + "@rollup/rollup-freebsd-x64": + optional: true + "@rollup/rollup-linux-arm-gnueabihf": + optional: true + "@rollup/rollup-linux-arm-musleabihf": + optional: true + "@rollup/rollup-linux-arm64-gnu": + optional: true + "@rollup/rollup-linux-arm64-musl": + optional: true + "@rollup/rollup-linux-loong64-gnu": + optional: true + "@rollup/rollup-linux-loong64-musl": + optional: true + "@rollup/rollup-linux-ppc64-gnu": + optional: true + "@rollup/rollup-linux-ppc64-musl": + optional: true + "@rollup/rollup-linux-riscv64-gnu": + optional: true + "@rollup/rollup-linux-riscv64-musl": + optional: true + "@rollup/rollup-linux-s390x-gnu": + optional: true + "@rollup/rollup-linux-x64-gnu": + optional: true + "@rollup/rollup-linux-x64-musl": + optional: true + "@rollup/rollup-openbsd-x64": + optional: true + "@rollup/rollup-openharmony-arm64": + optional: true + "@rollup/rollup-win32-arm64-msvc": + optional: true + "@rollup/rollup-win32-ia32-msvc": + optional: true + "@rollup/rollup-win32-x64-gnu": + optional: true + "@rollup/rollup-win32-x64-msvc": + optional: true + fsevents: + optional: true + bin: + rollup: dist/bin/rollup + checksum: 10c0/e8b423417161ca0cc61c7ec8c63663835c046960ae8417ec39efed40fe934c034a8e28111954b22da457a7bcc28daeaaa5b1f5005a0665fe81ab4f7679cbfa50 + languageName: node + linkType: hard + "rollup@npm:^4.34.9, rollup@npm:^4.43.0": version: 4.63.4 resolution: "rollup@npm:4.63.4" @@ -8352,6 +9615,22 @@ __metadata: languageName: node linkType: hard +"shiki@npm:^2.1.0": + version: 2.5.0 + resolution: "shiki@npm:2.5.0" + dependencies: + "@shikijs/core": "npm:2.5.0" + "@shikijs/engine-javascript": "npm:2.5.0" + "@shikijs/engine-oniguruma": "npm:2.5.0" + "@shikijs/langs": "npm:2.5.0" + "@shikijs/themes": "npm:2.5.0" + "@shikijs/types": "npm:2.5.0" + "@shikijs/vscode-textmate": "npm:^10.0.2" + "@types/hast": "npm:^3.0.4" + checksum: 10c0/1f7adf5dae47e37a828f8a442dc98a378014c2f8793e4e1f3562b85be23b295456ba82fded08da61d1f9d35917e6fb98517c6b6f43c65d972f7cbca6ecd596c9 + languageName: node + linkType: hard + "shiki@npm:^4.0.2": version: 4.4.3 resolution: "shiki@npm:4.4.3" @@ -8455,6 +9734,13 @@ __metadata: languageName: node linkType: hard +"speakingurl@npm:^14.0.1": + version: 14.0.1 + resolution: "speakingurl@npm:14.0.1" + checksum: 10c0/1de1d1b938a7c4d9e79593ff7a26d312ec04a7c3234ca40b7f9b8106daf74ea9d2110a077f5db97ecf3762b83069e3ccbf9694431b51d4fcfd863f0b3333c342 + languageName: node + linkType: hard + "stackback@npm:0.0.2": version: 0.0.2 resolution: "stackback@npm:0.0.2" @@ -8606,6 +9892,15 @@ __metadata: languageName: node linkType: hard +"superjson@npm:^2.2.2": + version: 2.2.6 + resolution: "superjson@npm:2.2.6" + dependencies: + copy-anything: "npm:^4" + checksum: 10c0/b63587656cc27effd12c9e13a66da673a7a1b3e1af41f17820fd37f07a4f29503e1f58eae6bd229039331b475e1be6990336fa887654e6adc80684ae4eed7965 + languageName: node + linkType: hard + "supports-preserve-symlinks-flag@npm:^1.0.0": version: 1.0.0 resolution: "supports-preserve-symlinks-flag@npm:1.0.0" @@ -8630,6 +9925,13 @@ __metadata: languageName: node linkType: hard +"tabbable@npm:^6.4.0": + version: 6.5.0 + resolution: "tabbable@npm:6.5.0" + checksum: 10c0/7d778af05a3093800265831198cad9d0024f3d65cdd4405007490f7430b42ff4e80060b4679f45281fb96ddbddc5fb895e8ea36e3ebdc811051e14acc9c7c02c + languageName: node + linkType: hard + "tailwind-merge@npm:^3.7.0": version: 3.7.0 resolution: "tailwind-merge@npm:3.7.0" @@ -9340,6 +10642,49 @@ __metadata: languageName: node linkType: hard +"vite@npm:^5.4.14": + version: 5.4.21 + resolution: "vite@npm:5.4.21" + dependencies: + esbuild: "npm:^0.21.3" + fsevents: "npm:~2.3.3" + postcss: "npm:^8.4.43" + rollup: "npm:^4.20.0" + peerDependencies: + "@types/node": ^18.0.0 || >=20.0.0 + less: "*" + lightningcss: ^1.21.0 + sass: "*" + sass-embedded: "*" + stylus: "*" + sugarss: "*" + terser: ^5.4.0 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + bin: + vite: bin/vite.js + checksum: 10c0/468336a1409f728b464160cbf02672e72271fb688d0e605e776b74a89d27e1029509eef3a3a6c755928d8011e474dbf234824d054d07960be5f23cd176bc72de + languageName: node + linkType: hard + "vite@npm:^6.4.1": version: 6.4.3 resolution: "vite@npm:6.4.3" @@ -9519,6 +10864,42 @@ __metadata: languageName: node linkType: hard +"vitepress@npm:^1.6.4": + version: 1.6.4 + resolution: "vitepress@npm:1.6.4" + dependencies: + "@docsearch/css": "npm:3.8.2" + "@docsearch/js": "npm:3.8.2" + "@iconify-json/simple-icons": "npm:^1.2.21" + "@shikijs/core": "npm:^2.1.0" + "@shikijs/transformers": "npm:^2.1.0" + "@shikijs/types": "npm:^2.1.0" + "@types/markdown-it": "npm:^14.1.2" + "@vitejs/plugin-vue": "npm:^5.2.1" + "@vue/devtools-api": "npm:^7.7.0" + "@vue/shared": "npm:^3.5.13" + "@vueuse/core": "npm:^12.4.0" + "@vueuse/integrations": "npm:^12.4.0" + focus-trap: "npm:^7.6.4" + mark.js: "npm:8.11.1" + minisearch: "npm:^7.1.1" + shiki: "npm:^2.1.0" + vite: "npm:^5.4.14" + vue: "npm:^3.5.13" + peerDependencies: + markdown-it-mathjax3: ^4 + postcss: ^8 + peerDependenciesMeta: + markdown-it-mathjax3: + optional: true + postcss: + optional: true + bin: + vitepress: bin/vitepress.js + checksum: 10c0/bffdd6fcfdfa72ff48228f7361d596e2279ebdbde9684b87cf66f7ef12ba096cb6eb931a1aff82038bdc579bb287e0982ba28f9416ed47af18e720a93b332d71 + languageName: node + linkType: hard + "vitest@npm:^5.0.1": version: 5.0.1 resolution: "vitest@npm:5.0.1" @@ -9811,6 +11192,24 @@ __metadata: languageName: node linkType: hard +"vue@npm:^3.5.13": + version: 3.5.43 + resolution: "vue@npm:3.5.43" + dependencies: + "@vue/compiler-dom": "npm:3.5.43" + "@vue/compiler-sfc": "npm:3.5.43" + "@vue/runtime-dom": "npm:3.5.43" + "@vue/server-renderer": "npm:3.5.43" + "@vue/shared": "npm:3.5.43" + peerDependencies: + typescript: "*" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/0b065772363e6f6358c70958d99cee8f5e8cc5dc773676380a06c737714b9a162721cade9c16fe9ff87aea569e2ce5cd2da2f1db5f510357b69d575f9542ded9 + languageName: node + linkType: hard + "web-streams-polyfill@npm:^3.0.3": version: 3.3.3 resolution: "web-streams-polyfill@npm:3.3.3" diff --git a/全流程链路测试报告.md b/全流程链路测试报告.md new file mode 100644 index 0000000..c8c2ac1 --- /dev/null +++ b/全流程链路测试报告.md @@ -0,0 +1,336 @@ +# 帆幕(Fanmu)产品全流程链路测试报告 + +- **测试日期**:2026-09-26 +- **测试方式**:真实浏览器(Playwright)逐流程操作 + 接口层复现取证 +- **测试环境**: + - 主测实例:`http://localhost:4321`(Docker 容器,生产构建 `NODE_ENV=production`) + - 对照实例:`http://127.0.0.1:4327`(本地 `astro dev`,用于定位根因) + - 片源夹具:临时本地 WebDAV(`tmp-e2e-dav/`,2 个 webm 样例视频),测试后已停止 +- **测试账号**:`e2e_tester`、`e2e_mail`(主实例)、`dev2`、`dev3`(dev 实例,密码已重置为 `Devpass456`) +- **注意**:主实例中 `e2e_tester` 的挂载已在「删除挂载」测试中删除;媒体库残留 1 条刮削条目(见问题 P2-5) + +--- + +## 一、测试结论摘要 + +**主链路可以走通**:注册登录 → 挂载添加/测试连接 → 目录浏览 → 扫描入库 → 媒体库(列表/海报墙/匹配刮削)→ 播放(流代理、进度保存)→ 弹幕(导入、渲染、开关/密度/透明度)→ 登出/再登录回跳,均实测通过,且鉴权回跳、多用户隔离、i18n 切换等细节闭环做得不错。 + +**但存在 3 处闭环断裂(P1)**: +1. 登录后访问 `/dashboard` 必现 500(用户菜单「仪表盘」是死链); +2. **生产模式下找回密码流程无法完成**(无令牌、无邮件通道); +3. **播放页导入的弹幕刷新即丢**(服务端只存了字节数,不存内容)。 + +另有删除挂载无确认/留孤儿数据、校验错误暴露原始 JSON 等一批 P2 可用性问题。 + +**第二轮(播放能力专项,见第七章)**:流代理 Range/206/416 全部规范;进度上报与续播精确(首页/媒体库双入口带百分比);多集切换与中间集结束引导完整;弹幕时间轴联动正常。新增发现:**孤儿集条目混入剧集列表、点中即黑屏且无错误提示(P2 升级)**、`/api/stream` 错误码语义问题,以及快捷键 f/m 不可用、末集无结束引导等 P3 项。 + +--- + +## 二、全流程闭环矩阵 + +| # | 流程 | 实测结果 | 判定 | +|---|------|---------|------| +| 1 | 首页 → 各导航入口 | 5 个主入口全部可达,编号导航清晰 | ✅ 闭环 | +| 2 | 游客访问受保护页(/library 等) | 302 → `/auth/login?next=…`,登录后回跳原页(实测回跳 `/mounts` 成功) | ✅ 闭环 | +| 3 | 错误凭据登录 | toast「用户名或密码不正确」 | ✅ 闭环 | +| 4 | 注册(弱密码 123) | 浏览器原生校验拦截(min=8),但表单无前置规则提示 | ⚠️ 可用 | +| 5 | 注册(合法) | 成功 → 首页 → 顶栏显示用户名,自动登录 | ✅ 闭环 | +| 6 | 挂载:非法地址保存 | 400 返回,但 toast 显示**原始 JSON 数组** | ❌ 不友好 | +| 7 | 挂载:空名称点保存 | 无任何提示,仅静默聚焦到名称字段 | ⚠️ 隐晦 | +| 8 | 挂载:保存成功 | 列表即时刷新、计数+1,但**无成功 toast** | ⚠️ 反馈弱 | +| 9 | 挂载:测试连接(坏地址/好地址) | 内联显示「连接失败 / 连接成功」,失败无原因说明 | ⚠️ 可用 | +| 10 | 挂载:编辑(含密码留空语义) | 进入编辑态、卡片标「编辑中」、密码占位「留空保持不变」 | ✅ 闭环 | +| 11 | 文件浏览:目录/面包屑/上一级/根目录 | 全部正常,中文目录名处理正确 | ✅ 闭环 | +| 12 | 文件浏览:目录读取失败 | 显示「目录读取失败」,无重试按钮、无具体原因 | ⚠️ 弱 | +| 13 | 扫描入库 | 「扫描完成:2 个文件」+ CTA「去媒体库整理」 | ✅ 闭环(优秀) | +| 14 | 媒体库:列表视图 | 已扫条目齐全,带「播放」「匹配」操作 | ✅ 闭环 | +| 15 | 媒体库:海报墙(未刮削时) | 空白 + 文案;不提示切列表视图;**搜索无结果复用同一文案** | ⚠️ 误导 | +| 16 | 匹配刮削(Bangumi 搜索→绑定) | 搜索结果正常,绑定后改名、入海报墙分组、匹配按钮消失 | ✅ 闭环(优秀) | +| 17 | 播放(流代理 + Range) | 正常播放,时长正确,进度到 00:04/00:04 | ✅ 闭环 | +| 18 | 播放进度持久化 | 服务端 `positionMs=4000` 每 5s/暂停/离开时上报;看完整片从头播(合理) | ✅ 闭环 | +| 19 | 弹幕导入 XML | 「弹幕 5 条」+ 逐条渲染上屏 | ⚠️ **刷新即丢**(P1) | +| 20 | 弹幕设置(开关/密度/透明度) | 保存 toast「已保存(本地会话)」,播放页即时生效 | ✅ 闭环(本地) | +| 21 | 修改密码(错误/正确当前密码) | 「当前密码不正确」/「密码已更新」,当前会话保留 | ✅ 闭环 | +| 22 | 登出 | 顶栏恢复 登录/注册,数据清空 | ✅ 闭环 | +| 23 | 找回密码(不存在邮箱) | 「若邮箱存在,重置申请已受理」(防枚举正确) | ✅ 闭环 | +| 24 | 找回密码(存在邮箱,**生产实例**) | 响应 `{}`:无令牌、无邮件通道 → **流程无法完成** | ❌ **断裂** | +| 25 | 找回密码(dev 实例) | 令牌 +「使用此令牌重置」→ 重置页令牌预填 → 新密码登录成功 | ✅ 闭环(仅 dev) | +| 26 | 删除挂载 | **无二次确认、无成功提示,直接删除** | ❌ 不安全 | +| 27 | 删除挂载后播放旧条目 | 流 502,播放器只显示「重新连接: 5」,无业务解释 | ❌ 孤儿数据 | +| 28 | 用户菜单「仪表盘」 | `/zh-CN/dashboard` **500**(dev:ResponseSentError) | ❌ **断裂** | +| 29 | 404 页 | 正确 404 状态码 + 说明 + 返回首页 | ✅ 闭环 | +| 30 | 中英文切换 | cookie 写入、路径保留、英文界面翻译完整 | ✅ 闭环 | +| 31 | 多用户数据隔离 | `e2e_mail` 看不到 `e2e_tester` 的挂载 | ✅ 闭环 | +| 32 | 媒体库搜索 | 命中正常;**无结果时文案误导**(见 P3-11) | ⚠️ 弱 | +| 33 | 无挂载空态(浏览页) | 禁用下拉 + 「添加挂载」CTA 跳转正确 | ✅ 闭环(优秀) | + +--- + +## 三、问题清单 + +### P1 — 功能闭环断裂(必须修复) + +**P1-1 登录后访问仪表盘必现 500(用户菜单死链)** +- 复现:登录任意账号 → 顶栏头像菜单 →「仪表盘」→ 页面显示 Internal server error。 +- 根因(dev 实例报错页证实):`app/web/src/views/dashboard.astro` 在流式渲染中执行 `return Astro.redirect(…)`,抛出 `ResponseSentError: The response has already been sent`(`astro/render.js:158`)。 +- 影响:菜单常驻入口是死链;现有 e2e 未覆盖登录后 dashboard,故未被发现。 +- 建议:把重定向移到 `pages/[locale]/dashboard.astro`(页面级、渲染前)或中间件层;或干脆让 `/dashboard` 直接渲染媒体库内容。并补一条登录态 e2e。 + +**P1-2 生产模式下「找回密码」无法完成** +- 复现(生产实例):注册带邮箱账号 → 申请重置 → 接口 200 但响应 `{}`(`auth.service.ts` 中 `if (IS_PROD) return {}`),页面无令牌、后端无邮件发送实现 → 用户拿不到重置令牌,流程终止。 +- dev 实例有「开发环境令牌(生产请接入邮件发送)」兜底,生产没有任何兜底。 +- 建议:接入邮件发送(SMTP/API),或至少在生产日志/管理端暴露令牌并给出明确的「请联系管理员」降级提示;上线前把该流程列入验收。 + +**P1-3 播放页导入弹幕刷新即丢** +- 复现:播放页「导入本地 XML」→ 显示「弹幕 5 条」并渲染 → 刷新/离开页面 → 变回「暂无在线弹幕」。 +- 根因:`danmakuService.importMeta` 只校验 `byteSize` 后返回 `{ok:true}`,**XML 内容不落库也不落 localStorage**;内容仅存在 `WatchBody` 组件 state。 +- 影响:核心卖点「弹幕观影」的导入闭环断裂,且用户被成功 toast 误导认为已保存。 +- 建议:将 XML 存入服务端(按 `mediaItemId` 持久化,或复用 `danmakuCacheDao`),或至少写 localStorage;并把 `danmakuSaveSettings/GetSettings` 的服务端桩实现补真。 + +### P2 — 可用性与数据安全(应当修复) + +**P2-4 删除挂载无确认、无成功反馈** +- 点「删除」立即生效:无确认对话框、无撤销、无 toast,列表直接清空。挂载配置录入成本高,误触即丢。 +- 建议:加确认(名称确认或二次弹窗)+ 删除成功 toast。 + +**P2-5 删除挂载产生孤儿媒体条目** +- 实测:删除挂载后媒体库海报/剧集仍在,点播放 → `/api/stream` 502,播放器仅显示「重新连接: 5」重试耗尽,无「片源已失效/挂载已删除」说明,也无处理入口(删除/重新挂载后重扫)。 +- 建议:删除挂载时提示影响范围(N 个条目将失效)并可选级联清理;条目侧检测挂载缺失时给出业务级错误页(带「返回媒体库」)。 + +**P2-6 校验错误暴露原始 JSON** +- 实测 toast 内容:`[ { "code": "invalid_format", "format": "url", "path": ["baseUrl"], "message": "需要合法 URL" } ]`。 +- 建议:客户端统一提取 zod 错误的 `message`(或按字段渲染行内错误),不要把序列化结果直接展示。 + +**P2-7 非 JSON 错误响应未兜底** +- 实测曾出现一次:`/api/trpc/media.mountCreate` 返回 500 HTML 错误页 → toast 显示 `Unexpected token '<', "<!DOCTYPE "... is not valid JSON`。任何网关/HTML 错误都会以解析异常的形式暴露给用户。 +- 建议:tRPC 链路统一包装,非 2xx 且非 JSON 时降级为「网络异常,请稍后重试」。 + +**P2-8 成功反馈不一致** +- 扫描完成、测试连接、改密码、重置申请都有明确文案;但**挂载保存/保存修改无任何成功提示**(仅靠列表变化感知)。 +- 建议:补「挂载已保存」toast,统一反馈策略。 + +**P2-9 「测试连接」失败无原因** +- 只显示「连接失败」,无法区分 DNS 失败 / 超时 / 认证 401 / 地址非 WebDAV,用户排障只能盲猜。 +- 建议:透出服务端归一化的失败原因(`assertSafeWebdavUrl` 已有细分文案,连接测试同样可细分)。 + +**P2-10 表单即时校验缺位** +- 空名称点「保存」:无提示,仅焦点跳到名称;非法 URL 客户端不拦截,全靠服务端 400。 +- 建议:提交时对首个错误字段做行内红字提示(不只靠 focus)。 + +### P3 — 体验细节(建议优化) + +| # | 问题 | 建议 | +|---|------|------| +| P3-11 | 媒体库「搜索无结果」与「尚无刮削作品」共用同一文案,误导 | 无结果时显示「未找到匹配“xxx”」+ 清除搜索按钮 | +| P3-12 | 海报墙/列表视图偏好不持久,每次进入回默认 | localStorage 记住视图 | +| P3-13 | 已登录状态下首页 hero 仍显示「登录 / 注册」CTA | 按登录态换成「继续观看/打开媒体库」 | +| P3-14 | 找回密码页 label 仍是「邮箱(可选,用于找回密码)」 | 该场景改为必填语义「注册邮箱」 | +| P3-15 | 关闭弹幕开关后,已导入 XML 被隐藏但文案仍说「暂无…可导入」 | 提示「弹幕已关闭,去设置开启」 | +| P3-16 | `/zh-CN/403`、`/zh-CN/500` 不存在(落到 404);404 页按 **cookie** 语言渲染,en cookie 下 `/zh-CN/404` 显示英文且「Back home」指向 `/en` | 404/403 视图按 URL locale 渲染;补路由 | +| P3-17 | 密码规则(≥8)只靠浏览器原生气泡,提交前不可见 | 表单下方常显规则文案 + 强度提示 | +| P3-18 | 弹幕设置仅 localStorage(服务端接口为桩),多设备不同步(UI 已诚实标注「本地会话」) | 若要多端一致,补服务端持久化 | +| P3-19 | 播放结束态仅在「有下一集」时出现引导,单集内容无「重播」入口 | 结束后给重播/返回系列按钮 | + +### 观察项(非缺陷) + +- 登录页密码框缺少 `autocomplete="current-password"`(浏览器 VERBOSE 提示);注册页同理。 +- 曾观测到一次 `/api/trpc/*` 全量 500 HTML,随后恢复(疑似容器/构建热切换窗口期),未再复现;P2-7 的客户端兜底可覆盖此类瞬态故障。 + +--- + +## 四、操作友好性评价(做得好的地方) + +1. **鉴权回跳完整**:受保护页 → 登录 → `next` 回跳实测成功,游客/登录态切换干净。 +2. **关键节点反馈清晰**:登录失败、密码错误、重置受理(防枚举)、扫描完成、测试连接成功/失败、弹幕开关保存,都有即时反馈。 +3. **CTA 引导链路好**:首页「快速开始」四步 → 扫描完成「去媒体库整理」 → 无挂载空态「添加挂载」,每一步都有下一步入口,是全流程最亮眼的设计。 +4. **刮削体验完整**:Bangumi 搜索 → 绑定 → 海报墙分组即时呈现,条目标题同步更新。 +5. **细节语义到位**:编辑态「编辑中」徽标、密码「留空保持不变」占位、「凭据仅保存在服务端」说明、「已保存(本地会话)」的诚实措辞。 +6. **空态覆盖好**:尚未添加挂载、暂无刮削作品、404 均有说明性文案与出路。 +7. **i18n 执行彻底**:切换语言保留当前路径、双语文案完整、cookie 优先级正确。 +8. **多用户隔离正确**:跨账号看不到彼此挂载与媒体数据。 +9. **进度保存可靠**:定时 + 暂停 + 离开页面(beacon)多时机上报,服务端实测落库。 + +--- + +## 五、现有自动化测试盲区 + +`e2e/` 仅 16 条用例,全部是**游客视角**:首页渲染、404、i18n 跳转、API 冒烟、登录/注册页可达。 + +完全未覆盖: +- 登录成功后的主链路(挂载 → 扫描 → 媒体库 → 播放 → 弹幕); +- 登录态 dashboard(P1-1 因此漏网); +- 找回密码/重置全链路(P1-2 漏网); +- 破坏性操作(删除挂载)与错误分支(非法输入、失效挂载播放)。 + +**建议**:补一条 Playwright 登录态冒烟链(约 10 步即可覆盖 P1 全部场景)。 + +--- + +## 六、修复优先级建议 + +1. **先修 P1-1**(死链,改动小:dashboard 重定向位置); +2. **P1-3**(弹幕持久化,核心卖点闭环); +3. **P1-2**(找回密码生产交付通道,上线阻塞项); +4. **P2-4/P2-5**(删除确认 + 孤儿数据,数据安全); +5. **P2-6/P2-7**(错误文案归一化,用户可感知质量提升最明显的一档); +6. 其余 P2/P3 随迭代处理,并同步补登录态 e2e。 + +--- + +## 七、播放能力专项测试(第二轮 · 补充) + +> 环境:重建 WebDAV 夹具(新增 6s 的 EP02 用于多集测试),重建挂载并重新扫描;接口层用 curl 带会话 Cookie 直测 `/api/stream`;播放器行为用浏览器自动化逐项实测。 + +### 7.1 流代理 `/api/stream`(接口层) + +| 场景 | 结果 | 判定 | +|------|------|------| +| 无 Range GET | 200,`Accept-Ranges: bytes`,`video/webm`,长度正确 | ✅ | +| `Range: bytes=0-99` | 206,`Content-Range: bytes 0-99/112733`,实际 100 字节 | ✅ | +| `Range: bytes=100-199`(seek 分段) | 206,精确 100 字节 | ✅ | +| `Range: bytes=-50`(尾部) | 206,`bytes 112683-112732/112733` | ✅ | +| 越界 Range | 416 + `Content-Range: bytes */total`(规范) | ✅ | +| 分段内容一致性 | 前 100 字节与源文件 `cmp` 一致 | ✅ | +| 并发 5 路 Range | 全部 206 | ✅ | +| 无会话 Cookie | 401 | ✅ | +| 不存在的 mediaId | **502** + 正文「媒体或挂载不存在」 | ⚠️ 应为 404 | +| 挂载已删的孤儿条目 | **502** + 同上文案 | ⚠️ 条目明明在列表中,文案自相矛盾 | + +### 7.2 播放器交互 + +| 能力 | 实测 | 判定 | +|------|------|------| +| 播放 / 暂停(UI 按钮) | 正常,暂停即时上报进度(实测 2.1s → 服务端 2103ms 精确) | ✅ | +| 进度条点击 seek | 点 50% → t=2.0(总长 4s)精确 | ✅ | +| 进度条拖拽 seek | 拖至 75% → t=3.0 精确 | ✅ | +| 倍速(设置面板 0.5–2.0) | 选 2.0 后 `playbackRate=2` 生效 | ✅ | +| 音量(hover 面板 / 图标静音) | 面板可见可调;图标切换 `muted` 生效 | ✅ | +| 全屏按钮 | 点击进入 `fullscreenElement`,Escape 退出 | ✅ | +| 画中画按钮 | 按钮存在,环境 `pictureInPictureEnabled=true` | ✅ | +| 键盘 Space | 播放/暂停切换生效 | ✅ | +| 键盘 ← → | 快退/快进(±5s,越界钳制到 0/duration)生效 | ✅ | +| 键盘 ↑ ↓ | 音量 ± 生效 | ✅ | +| 键盘 f / m | **无效** —— ArtPlayer 默认 hotkey 不注册这两个键(源码确认),UI 按钮可用;属体验建议而非故障 | ⚠️ P3 | +| 弹幕快速开关(播放器内按钮) | 切换生效,`artplayerPluginDanmuku:config` 回写 localStorage 偏好,下次进入继承 | ✅ | + +### 7.3 进度与续播体系(全链路) + +| 场景 | 实测 | 判定 | +|------|------|------| +| UI 暂停上报 | 位置精确(2103ms) | ✅ | +| 离开页面(pagehide beacon) | 暂停态 seek 1.5s 后离开 → 服务端 1500ms | ✅ | +| 重新进入续播 | 恢复 1.5s / 2.1s / 5.19s 三次均精确,就绪后暂停等用户播放 | ✅ | +| 看完再进 | position ≥ duration 不跳转,从头播 | ✅ 合理 | +| 首页「继续观看」 | 有未完成进度时出现;点击跳 watch 精确续播 | ✅ | +| 媒体库「继续播放 · 87%」 | 带百分比;点击 → t=5.19/6.0 精确 | ✅ | +| 已看完条目 | 正确从「继续观看」中消失(`position < duration` 过滤) | ✅ | +| 程序化 `video.pause()`(系统媒体键等) | **不触发上报**——代码监听的是组件事件 `art.on("pause")`,原生暂停只发 `video:pause` | ⚠️ P3-3 | +| 5s 定时 + 播放/暂停/结束多时机上报 | 均实测生效 | ✅ | + +### 7.4 多集系列与结尾引导 + +| 场景 | 实测 | 判定 | +|------|------|------| +| EP01/EP02 绑定同一 Bangumi | 海报墙聚合「进击的巨人 2 集」 | ✅ | +| 系列详情 | 集列表(EP1/EP2)+ 每集播放/匹配 | ✅ | +| 播放页剧集导航 | 「上一集/下一集」可用性正确(首集禁上、末集禁下),当前集高亮 | ✅ | +| 下一集切换 | 跳 EP02,时长 6s 正确,URL 带 `from=series&g=` | ✅ | +| 中间集播放结束 | 出现「第 2 集 / 播放下一集」引导卡,点击跳转成功 | ✅ | +| 「返回系列」 | 回 `/library?g=55770&...` 系列详情 | ✅ | +| 末集 / 单集播放结束 | **无任何结束引导**(无重播、无返回系列 CTA),隐性入口仅中央播放键 | ⚠️ P3-4 | +| 点「播放下一集」后 URL | 丢失 `g/from` 参数(分享该 URL 丢失上下文;页内返回按钮仍可用) | ⚠️ P3 | +| ⚠️ 剧集列表出现**两个「第 1 集」** | 挂载删除产生的孤儿条目与新条目同 epNumber 并列 | ❌ P2-5 升级 | +| 点中孤儿集(列表第一项) | 流 502 → `MEDIA_ELEMENT_ERROR: Format error` → duration=NaN;重连计数结束后**静默黑屏,无错误文案、无重试、无出路** | ❌ **P2-5 最严重表现** | + +### 7.5 弹幕 × 播放 + +| 场景 | 实测 | 判定 | +|------|------|------| +| 导入后渲染(官方插件 artplayer-plugin-danmuku) | 文本节点实际出现 | ✅ | +| 时间轴同步 | t≈1.5 弹幕在 1.5–2.3s 出现;seek 到 4.3 后 4.6s 弹幕出现 | ✅ | +| seek 后重新定位 | 回拖后对应时刻弹幕重新发出 | ✅ | +| 插件设置面板 | 按类型屏蔽(滚动/顶部/底部)、防止弹幕重叠、同步视频速度、透明度 | ✅ 能力超预期 | +| 弹幕开关联动 | `dm.show()/hide()` + 偏好回写 localStorage | ✅ | + +### 7.6 第二轮新增问题清单 + +| 级别 | 问题 | 建议 | +|------|------|------| +| **P2(升级)** | 孤儿条目混入剧集列表且与正常集重名,点中即黑屏(502→Format error→静默),用户无法分辨也无法自救 | 根治:删除挂载时级联清理/标记失效条目(同 P2-5);播放侧:加载失败时展示业务级错误卡(原因+重试+返回),不要停在 00:00/00:00 | +| **P2** | `/api/stream` 对「不存在」与「挂载失效」统一 502,且正文文案「媒体或挂载不存在」与媒体库列表可见相矛盾 | 分别返回 404 / 410(或 502+结构化 code),UI 消费文案生成对应提示 | +| P3 | 原生 `video.pause()` 不触发进度上报(仅组件事件 `art.on("pause")`) | 追加 `art.on("video:pause")` 上报(主路径已正常,覆盖系统媒体键/锁屏场景) | +| P3 | 末集/单集播放结束无引导 | 结束后展示「重播 / 返回系列」动作卡 | +| P3 | 快捷键 f(全屏)/ m(静音)不可用 | 自定义注册 hotkey,或在提示中标注可用按键 | +| P3 | 「播放下一集」跳转后 URL 丢 `g/from` | openEpisode 保留 series 上下文参数 | +| P3 | 筛选无结果时空态文案复用「暂无媒体,请先在文件浏览中扫描挂载」(第二轮再次复现,叠加挂载 tab 过滤时更易触发) | 区分「筛选无结果」与「确实无数据」两套文案 | + +### 7.7 第二轮正面结论 + +- **流代理的 HTTP 语义是全项目最扎实的部分**:Range/206/416/鉴权/字节一致性全部规范。 +- **进度体系闭环质量高**:多时机上报 + 精确恢复 + 双入口带百分比 + 已看完过滤,实测无一次偏差。 +- **多集导航与结束引导**(中间集)完整;弹幕与播放的时间轴联动、偏好持久化均正常。 +- 主路径(UI 按钮暂停、seek、切集)进度上报全部精确——上报缺口仅限程序化暂停的边缘场景。 + +--- + +## 八、优化修复与回归验证(第三轮) + +> 修复范围:两轮报告全部 P1/P2/P3 项。验证环境为本地 dev 实例(127.0.0.1:4327,重启后加载新构建);**4321 Docker 实例跑的是旧镜像,需重新构建部署才能获得以下修复**。 + +### 8.1 修复对照表 + +| 级别 | 问题 | 修复方式 | 回归结果 | +|------|------|---------|---------| +| P1-1 | /dashboard 登录态 500 | 重定向移到**页面级** `pages/[locale]/dashboard.astro`(渲染前),删除子视图内 redirect 与死代码(views/dashboard、DashboardBody、UserList) | ✅ /dashboard → 302 → /library | +| P1-2 | 生产找回密码无交付通道 | 新增 `deliverResetToken`:配置 `PASSWORD_RESET_WEBHOOK_URL` 时 POST `{email,username,token,resetPath}`;未配置时**写服务日志**(含令牌与链接);UI 提示文案更新;`.env.example` 登记 | ✅ 日志输出 `[password-reset] user=dev3 … token=… link=…`;toast 新文案 | +| P1-3 | 弹幕导入刷新即丢 | 导入 XML 按 `media-danmaku-xml:<id>` 写 localStorage(2MB 上限),进入/切换条目时恢复 | ✅ 导入2条 → 刷新仍「弹幕 2 条」 | +| P2-4 | 删除挂载无确认 | 两步确认(按钮变「确认删除?」+ 影响提示),成功 toast 带级联数量 | ✅ 确认态/「挂载已删除」toast | +| P2-5 | 孤儿条目(黑屏/剧集重复/列表僵尸) | ① `mountDelete` **级联删除**媒体条目+播放进度;② `libraryService` list/groups/get/series 前**惰性清理孤儿**;③ WatchBody 监听 `video:error` 弹出**业务级错误卡**(原因+重试+返回媒体库) | ✅ 删除后条目/进度全清;手工插入孤儿 → 自动清理 + 页面显示「加载失败」卡(不再黑屏);停 WebDAV 播放 → 错误卡 → 恢复后「重试」成功 | +| P2-6 | 校验错误暴露原始 JSON | 新增 `formatTrpcError`(提取 zod 数组消息/HTML 兜底),挂载保存/删除等 onError 全部接入 | ✅ | +| P2-7 | 非 JSON 错误响应未兜底 | `lib/trpc.ts` 自定义 `safeFetch`:非 JSON 错误响应转换为结构化 tRPC 错误 | ✅(typecheck/链路验证) | +| P2-8 | 保存成功无反馈 | 挂载创建/更新/删除 toast 链路核验 | ✅ 「挂载已保存」「挂载已更新」「挂载已删除」 | +| P2-9 | 测试连接失败无原因 | `mountService.test` 归一化错误(认证失败/无法解析地址/连接被拒绝/超时/证书/非 WebDAV) | ✅ 显示「连接被拒绝,请检查地址和端口」 | +| P2-10 | 表单静默校验 | 行内红字(名称必填、http(s) 合法性)+ 焦点定位,非法提交**零网络请求** | ✅ 两条红字 + 请求数 0 | +| P2 | stream 错误码语义 | `StreamSourceError`:媒体不存在 404 / 挂载失效 410 / 上游失败 502;`mountListDir` 失败改 BAD_GATEWAY+具体原因 | ✅ 孤儿条目接口 404 NOT_FOUND | +| P3-11 | 空结果文案误导 | 区分「搜索无结果(可清除搜索)/筛选无结果(可切全部)/真无数据(引导扫描)」 | ✅ 三层文案与恢复按钮 | +| P3-12 | 视图偏好不持久 | `media-library-view` localStorage | ✅ 跨页面刷新保持「列表」 | +| P3-13 | 已登录首页仍显示登录 CTA | `auth.me` 判断登录态,隐藏「登录 / 注册」 | ✅ | +| P3-14 | 找回密码邮箱 label | forgot 模式专用「注册邮箱」 | ✅ | +| P3-15 | 弹幕关闭文案 | 已导入时显示「弹幕已关闭,可在上方按钮或弹幕设置中开启」 | ✅ | +| P3-16 | 404 语言不随路径 | 404 页优先取 URL locale 段 | ✅ en cookie 下 `/zh-CN/*` 显示中文、`/en/*` 英文 | +| P3-17 | 密码规则不可见 | 注册/改密/重置密码框下常显「至少 8 位字符」 | ✅ | +| P3-19 | 末集结束无引导 | 结束卡:「本集已播放完毕」+ 重新播放 + 返回系列/媒体库 | ✅ | +| 播放 | 原生暂停不报进度 | 监听改为 `video:play` / `video:pause`(原生事件转发,覆盖系统媒体键) | ✅ 程序化 pause → positionMs=702 精确上报 | +| 播放 | 快捷键 f/m 缺失 | 构造后注册 `hotkey KeyF/KeyM`(全屏/静音) | ✅ m 静音切换、f 进全屏 | +| 播放 | 目录读取失败无重试 | 浏览页错误行显示服务端原因 + 「重试」按钮 | ✅ | + +### 8.2 修复过程中发现并解决的阻塞问题 + +1. **工作区存在未提交 WIP**(models 新增 `dandanplay_episode_id`/`matched_hash` 列),但**未执行 `yarn db:push`**——导致媒体库查询/写入全部 500、扫描假成功。已执行 `db:push`(schema 变更已应用),媒体库恢复正常。**注意:Docker 实例的数据库需在其环境单独执行迁移。** +2. WIP 代码 `danmaku.service.ts` 存在 `exactOptionalPropertyTypes` 类型错误,已修复。 +3. 运行期 Vite 依赖预构建过期(504 Outdated Optimize Dep)导致页面水合失败,重启 dev server 恢复。 + +### 8.3 最终质量门禁 + +- `yarn typecheck`:19/19 任务通过 +- `yarn lint`:11/11 通过(仅 3 条存量 CSS specificity warning,非本次引入) +- `yarn build:web`:构建成功 +- 浏览器回归(dev 实例):8.1 表中全部 ✅ 项逐一手动验证 + +### 8.4 暂缓项与生效说明 + +| 项 | 说明 | +|----|------| +| P3-18 弹幕设置服务端持久化 | **暂缓**:需新增数据库表 + 双环境迁移;当前 localStorage 方案已用「本地会话」文案如实标注,单设备闭环完整。建议随下个 schema 变更窗口一并实施 | +| 4321 Docker 实例 | 仍是**旧构建**:本轮修复需重新 `docker build` 并对容器内数据库执行 `db:push` 后生效 | +| 现有 e2e 套件 | 仍指向 4321(旧镜像)且只覆盖游客路径;建议将 baseURL 指向新部署并补充登录态冒烟链(覆盖 dashboard/挂载/播放) | +| m/f 快捷键的全屏退出 | headless 环境下 Escape 未观察到退出全屏(进入正常);真实浏览器行为待人工确认 | + +--- + +## 附录:测试现场说明 + +- 临时 WebDAV 已停止;如需复测:`"$MIMO_PYTHON" tmp-e2e-dav/dav_server.py "$PWD/tmp-e2e-dav/dav-root" 8099`,挂载地址 `http://192.168.31.125:8099`(localhost 被 SSRF 策略禁止,须用局域网 IP)。 +- 样例视频位于 `tmp-e2e-dav/dav-root/片库/`(EP01 4s、EP02 6s、示例电影),弹幕样例 `tmp-e2e-dav/sample-danmaku.xml`。 +- 主实例遗留状态:`e2e_tester` 名下 2 条孤儿媒体条目(挂载删除残留;其中孤儿 EP1 与新 EP01 已同绑「进击的巨人」,即 7.4 节剧集列表重复的复现条件)+ 3 条有效条目;新增账号 `e2e_mail`。 +- 第二轮测试结束后 WebDAV 服务已停止,复测前需按第一条命令重启。