diff --git a/docs/superpowers/plans/2026-09-25-dandanplay-web.md b/docs/superpowers/plans/2026-09-25-dandanplay-web.md new file mode 100644 index 0000000..95ba0b7 --- /dev/null +++ b/docs/superpowers/plans/2026-09-25-dandanplay-web.md @@ -0,0 +1,2779 @@ +# Web 版弹弹play(媒体中心)实现计划 + +> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。 + +**目标:** 在 `app-template` 产品化全栈上实现多用户 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 代理。 + +**技术栈:** Astro 7 · React 19 · tRPC v11 · Drizzle/Turso · Zod · ArtPlayer · `webdav` (npm) · Tailwind 4 · Biome · Yarn 4 + +**规格:** `docs/superpowers/specs/2026-09-25-dandanplay-web-design.md` +**产品化说明:** 本仓库按产品仓维护;业务进入现有分层(对 AGENTS「模板不掺业务」的已批准豁免,仅限本产品功能)。 + +**测试运行器:** 仓库尚无单测。本计划在 `@app/types` 与 `@app/trpc` 引入 **Vitest**(`*.test.ts`)。 + +**环境变量(根 `.env` / `.env.example` 追加):** + +```bash +# 媒体挂载密钥(AES-256-GCM,32 字节 hex;可用 openssl rand -hex 32) +MEDIA_SECRET_KEY= +# 弹弹play 开放弹幕网络 API 基址(实现时以官方 open 文档为准,可覆盖) +OPEN_DANMAKU_API_BASE=https://api.dandanplay.net +# Bangumi API +BANGUMI_API_BASE=https://api.bgm.tv +``` + +--- + +## 文件结构 + +| 路径 | 职责 | +|------|------| +| `packages/types/src/media-schemas.ts` | 挂载/库/进度/弹幕/刮削 Zod + DTO | +| `packages/types/src/index.ts` | 重导出 media schemas | +| `packages/models/src/mounts.ts` 等 | 新表定义 | +| `packages/models/src/index.ts` | 聚合导出 | +| `packages/dao/src/mounts.ts` 等 | 新 dao | +| `packages/dao/src/index.ts` | 聚合导出 | +| `packages/trpc/src/services/secret.ts` | MEDIA_SECRET_KEY AES-GCM | +| `packages/trpc/src/services/webdav-client.ts` | 列目录 / HEAD / 打开 Range | +| `packages/trpc/src/services/mount.service.ts` | 挂载 CRUD + 连接测试 | +| `packages/trpc/src/services/library.service.ts` | 扫描入库 | +| `packages/trpc/src/services/scrape.service.ts` | Bangumi 搜索/绑定 | +| `packages/trpc/src/services/playback.service.ts` | 进度读写 | +| `packages/trpc/src/services/danmaku.service.ts` | 开放网络中继 + XML 导入元数据 | +| `packages/trpc/src/services/procedure.ts` | `protectedProcedure`(解析 userId) | +| `packages/trpc/src/router/media.router.ts` | mount/library/scrape/playback/danmaku 挂载 | +| `packages/trpc/src/router/index.ts` | appRouter 增加 `media` | +| `app/web/src/pages/api/stream.ts` | Range 流代理 | +| `app/web/src/pages/[locale]/{library,browse,watch,mounts,danmaku}.astro` | 路由 | +| `app/web/src/views/{library,browse,watch,mounts,danmaku}.astro` | SSR 壳 | +| `app/web/src/components/pages/{Library,Browse,Watch,Mounts,Danmaku}Body.tsx` | islands | +| `app/web/src/components/AppShell.tsx` | shadcn 风侧栏布局 | +| `app/web/src/middleware.ts` | PROTECTED_BASES 扩展 | +| `packages/i18n/src/messages/{zh-CN,en}.ts` | 文案 key | +| `app/web/package.json` | 依赖 `artplayer` | + +--- + +## 任务 1:media Zod schema + 单测 + +**文件:** +- 创建:`packages/types/src/media-schemas.ts` +- 修改:`packages/types/src/index.ts` +- 修改:`packages/types/package.json`(`test` 脚本 + vitest devDep) +- 测试:`packages/types/src/media-schemas.test.ts` + +- [ ] **步骤 1:写入失败的测试** + +```ts +// packages/types/src/media-schemas.test.ts +import { describe, expect, it } from "vitest"; +import { mountSchemas, playbackSchemas, scrapeSchemas } from "./media-schemas.js"; + +describe("mountSchemas.create", () => { + it("拒绝缺 baseUrl", () => { + const r = mountSchemas.create.safeParse({ name: "A", type: "webdav" }); + expect(r.success).toBe(false); + }); + it("接受合法 WebDAV", () => { + const r = mountSchemas.create.safeParse({ + name: "OpenList", + type: "webdav", + baseUrl: "https://dav.example.com/dav", + username: "u", + password: "p", + rootPath: "/anime", + }); + expect(r.success).toBe(true); + }); +}); + +describe("scrapeSchemas.bind", () => { + it("需要 mediaItemId 与 bangumiId", () => { + const r = scrapeSchemas.bind.safeParse({ mediaItemId: "m1", bangumiId: 123 }); + expect(r.success).toBe(true); + expect(scrapeSchemas.bind.safeParse({ mediaItemId: "m1" }).success).toBe(false); + }); +}); + +describe("playbackSchemas.report", () => { + it("positionMs ≥ 0", () => { + expect( + playbackSchemas.report.safeParse({ + mediaItemId: "x", + positionMs: -1, + durationMs: 1000, + }).success, + ).toBe(false); + }); +}); +``` + +- [ ] **步骤 2:安装 vitest 并跑测确认失败** + +```bash +yarn workspace @app/types add -D vitest +# package.json scripts: "test": "vitest run" +yarn workspace @app/types test +``` + +预期:FAIL(模块不存在)。 + +- [ ] **步骤 3:实现 schema** + +```ts +// packages/types/src/media-schemas.ts +import { z } from "zod"; + +const id = z.string().min(1); + +export const mountSchemas = { + create: z.object({ + name: z.string().min(1, "名称不能为空").max(100), + type: z.literal("webdav"), + baseUrl: z.string().url("需要合法 URL"), + username: z.string().max(200).optional(), + password: z.string().max(500).optional(), + rootPath: z.string().max(1000).default("/"), + enabled: z.boolean().default(true), + }), + update: z.object({ + id, + name: z.string().min(1).max(100).optional(), + baseUrl: z.string().url().optional(), + username: z.string().max(200).optional(), + password: z.string().max(500).optional(), + rootPath: z.string().max(1000).optional(), + enabled: z.boolean().optional(), + }), + delete: z.object({ id }), + list: z.object({}).optional(), + test: z.object({ + baseUrl: z.string().url(), + username: z.string().max(200).optional(), + password: z.string().max(500).optional(), + rootPath: z.string().max(1000).default("/"), + }), + listDir: z.object({ + mountId: id, + path: z.string().max(2000).default("/"), + }), +}; + +export const librarySchemas = { + list: z.object({ + limit: z.number().min(1).max(100).default(24), + offset: z.number().min(0).default(0), + scrapeStatus: z.enum(["ok", "unmatched", "failed", "pending"]).optional(), + q: z.string().max(200).optional(), + }), + scan: z.object({ mountId: id }), + get: z.object({ id }), +}; + +export const playbackSchemas = { + report: z.object({ + mediaItemId: id, + positionMs: z.number().min(0), + durationMs: z.number().min(0), + }), + get: z.object({ mediaItemId: id }), +}; + +export const scrapeSchemas = { + search: z.object({ + q: z.string().min(1, "请输入搜索词").max(200), + }), + bind: z.object({ + mediaItemId: id, + bangumiId: z.number().int().positive(), + }), + rescrape: z.object({ mediaItemId: id }), +}; + +export const danmakuSchemas = { + /** 开放网络:按媒体文件取弹幕 */ + fetch: z.object({ mediaItemId: id }), + /** 本地 XML:仅登记导入元数据(正文在客户端喂给播放器) */ + importMeta: z.object({ + mediaItemId: id, + byteSize: z.number().min(0).max(20_000_000), + }), + settings: z.object({ + enabled: z.boolean().default(true), + opacity: z.number().min(0).max(1).default(0.8), + density: z.number().min(0.1).max(3).default(1), + }), + getSettings: z.object({}).optional(), +}; + +export type MountCreateInput = z.infer; +export type MountListDirInput = z.infer; +export type LibraryListInput = z.infer; +export type PlaybackReportInput = z.infer; +export type ScrapeSearchInput = z.infer; +export type DanmakuFetchOutput = { + ok: boolean; + source: "open-network" | "cache" | "none"; + /** Bilibili/弹弹play 兼容 XML 字符串;无弹幕则为 "" */ + xml: string; + message?: string; +}; +``` + +在 `packages/types/src/index.ts` 追加: + +```ts +export * from "./media-schemas.js"; +``` + +`packages/types/package.json` scripts 增加: + +```json +"test": "vitest run" +``` + +- [ ] **步骤 4:跑测通过** + +```bash +yarn workspace @app/types test +yarn workspace @app/types typecheck +yarn workspace @app/types build +``` + +预期:PASS。 + +- [ ] **步骤 5:Commit** + +```bash +git add packages/types +git commit -m "feat(types): media mount/library/playback/scrape/danmaku schemas" +``` + +--- + +## 任务 2:models 新表 + 迁移 + +**文件:** +- 创建:`packages/models/src/mounts.ts` +- 创建:`packages/models/src/media-items.ts` +- 创建:`packages/models/src/playback-progress.ts` +- 创建:`packages/models/src/danmaku-cache.ts` +- 修改:`packages/models/src/index.ts` + +- [ ] **步骤 1:写入表定义** + +```ts +// packages/models/src/mounts.ts +import { sql } from "drizzle-orm"; +import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; +import { users } from "./users.js"; + +export const mounts = sqliteTable( + "mounts", + { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: text("name").notNull(), + type: text("type").notNull().default("webdav"), + baseUrl: text("base_url").notNull(), + username: text("username"), + /** AES-GCM 密文(base64),永不回传前端 */ + secretEnc: text("secret_enc").notNull().default(""), + rootPath: text("root_path").notNull().default("/"), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), + updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), + }, + (t) => [uniqueIndex("mounts_user_name_uq").on(t.userId, t.name)], +); +export type Mount = typeof mounts.$inferSelect; +export type NewMount = typeof mounts.$inferInsert; +``` + +```ts +// packages/models/src/media-items.ts +import { sql } from "drizzle-orm"; +import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; +import { users } from "./users.js"; +import { mounts } from "./mounts.js"; + +export const mediaItems = sqliteTable( + "media_items", + { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + mountId: text("mount_id").references(() => mounts.id, { onDelete: "set null" }), + path: text("path").notNull(), + rawName: text("raw_name").notNull(), + title: text("title").notNull(), + size: integer("size").notNull().default(0), + mime: text("mime"), + bangumiId: integer("bangumi_id"), + epNumber: integer("ep_number"), + scrapeStatus: text("scrape_status").notNull().default("pending"), + scrapedAt: integer("scraped_at", { mode: "timestamp" }), + posterUrl: text("poster_url"), + matchedHash: text("matched_hash"), + scannedAt: integer("scanned_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), + updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), + }, + (t) => [uniqueIndex("media_user_path_uq").on(t.userId, t.path)], +); +export type MediaItem = typeof mediaItems.$inferSelect; +export type NewMediaItem = typeof mediaItems.$inferInsert; + +// packages/models/src/playback-progress.ts +import { sql } from "drizzle-orm"; +import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; +import { users } from "./users.js"; +import { mediaItems } from "./media-items.js"; + +export const playbackProgress = sqliteTable( + "playback_progress", + { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + mediaItemId: text("media_item_id") + .notNull() + .references(() => mediaItems.id, { onDelete: "cascade" }), + positionMs: integer("position_ms").notNull().default(0), + durationMs: integer("duration_ms").notNull().default(0), + updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), + }, + (t) => [uniqueIndex("progress_user_media_uq").on(t.userId, t.mediaItemId)], +); +export type PlaybackProgress = typeof playbackProgress.$inferSelect; + +// packages/models/src/danmaku-cache.ts +import { sql } from "drizzle-orm"; +import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; + +export const danmakuCache = sqliteTable( + "danmaku_cache", + { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + matchKey: text("match_key").notNull(), + payload: text("payload").notNull(), + source: text("source").notNull().default("open-network"), + expiresAt: integer("expires_at", { mode: "timestamp" }), + createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), + }, + (t) => [uniqueIndex("danmaku_match_key_uq").on(t.matchKey)], +); +export type DanmakuCacheRow = typeof danmakuCache.$inferSelect; +``` + +`packages/models/src/index.ts` 追加导出(模式与现有一致): + +```ts +export { mounts } from "./mounts.js"; +export type { Mount, NewMount } from "./mounts.js"; +export { mediaItems } from "./media-items.js"; +export type { MediaItem, NewMediaItem } from "./media-items.js"; +export { playbackProgress } from "./playback-progress.js"; +export type { PlaybackProgress } from "./playback-progress.js"; +export { danmakuCache } from "./danmaku-cache.js"; +export type { DanmakuCacheRow } from "./danmaku-cache.js"; +``` + +- [ ] **步骤 2:typecheck + build models,再 push schema** + +```bash +yarn workspace @app/models typecheck +yarn workspace @app/models build +yarn db:push +``` + +预期:本地 `file:local.db` 出现新表;若无 `.env`,先 `cp .env.example .env`。 + +- [ ] **步骤 3:Commit** + +```bash +git add packages/models packages/db +git commit -m "feat(models): mounts/media_items/playback_progress/danmaku_cache" +``` + +--- + +## 任务 3:dao 新表访问层 + +**文件:** +- 创建:`packages/dao/src/mounts.ts` · `media-items.ts` · `playback-progress.ts` · `danmaku-cache.ts` +- 修改:`packages/dao/src/index.ts` + +- [ ] **步骤 1:实现四个 dao(模式对齐 `userDao`)** + +```ts +// packages/dao/src/mounts.ts +import { and, desc, eq } from "drizzle-orm"; +import { mounts, type Mount, type NewMount } from "@app/models"; +import { db } from "@app/db"; + +export type MountRow = Mount; + +export const mountDao = { + async listByUser(userId: string): Promise { + return db.select().from(mounts).where(eq(mounts.userId, userId)).orderBy(desc(mounts.createdAt)); + }, + async getByIdForUser(id: string, userId: string): Promise { + const [row] = await db + .select() + .from(mounts) + .where(and(eq(mounts.id, id), eq(mounts.userId, userId))) + .limit(1); + return row ?? null; + }, + async create(data: NewMount): Promise { + const rows = await db.insert(mounts).values(data).returning(); + const row = rows[0]; + if (!row) throw new Error("Failed to create mount"); + return row; + }, + async update(id: string, userId: string, data: Partial>): Promise { + const [row] = await db + .update(mounts) + .set({ ...data, updatedAt: new Date() }) + .where(and(eq(mounts.id, id), eq(mounts.userId, userId))) + .returning(); + return row ?? null; + }, + async delete(id: string, userId: string): Promise { + await db.delete(mounts).where(and(eq(mounts.id, id), eq(mounts.userId, userId))); + }, +}; +``` + +```ts +// packages/dao/src/media-items.ts +import { and, desc, eq, like, or, sql } from "drizzle-orm"; +import { mediaItems, type MediaItem, type NewMediaItem } from "@app/models"; +import { db } from "@app/db"; + +export type MediaItemRow = MediaItem; + +export const mediaItemDao = { + async list( + userId: string, + params: { limit: number; offset: number; scrapeStatus?: string | undefined; q?: string | undefined }, + ): Promise<{ rows: MediaItemRow[]; total: number }> { + const conds = [eq(mediaItems.userId, userId)]; + if (params.scrapeStatus) conds.push(eq(mediaItems.scrapeStatus, params.scrapeStatus)); + if (params.q) { + const likeQ = `%${params.q}%`; + conds.push(or(like(mediaItems.title, likeQ), like(mediaItems.rawName, likeQ))!); + } + const where = and(...conds); + const rows = await db + .select() + .from(mediaItems) + .where(where) + .orderBy(desc(mediaItems.updatedAt)) + .limit(params.limit) + .offset(params.offset); + const [countRow] = await db.select({ count: sql`count(*)` }).from(mediaItems).where(where); + return { rows, total: Number(countRow?.["count"] ?? 0) }; + }, + async getByIdForUser(id: string, userId: string): Promise { + const [row] = await db + .select() + .from(mediaItems) + .where(and(eq(mediaItems.id, id), eq(mediaItems.userId, userId))) + .limit(1); + return row ?? null; + }, + async getByUserPath(userId: string, path: string): Promise { + const [row] = await db + .select() + .from(mediaItems) + .where(and(eq(mediaItems.userId, userId), eq(mediaItems.path, path))) + .limit(1); + return row ?? null; + }, + async upsertFromScan(data: { + userId: string; + mountId: string; + path: string; + rawName: string; + title: string; + size: number; + mime?: string | undefined; + }): Promise { + const existing = await this.getByUserPath(data.userId, data.path); + if (existing) { + const [row] = await db + .update(mediaItems) + .set({ + size: data.size, + mime: data.mime, + updatedAt: new Date(), + scannedAt: new Date(), + }) + .where(eq(mediaItems.id, existing.id)) + .returning(); + if (!row) throw new Error("Failed to update media item"); + return row; + } + const rows = await db + .insert(mediaItems) + .values({ + userId: data.userId, + mountId: data.mountId, + path: data.path, + rawName: data.rawName, + title: data.title, + size: data.size, + mime: data.mime, + scrapeStatus: "pending", + }) + .returning(); + const row = rows[0]; + if (!row) throw new Error("Failed to insert media item"); + return row; + }, + async updateScrape( + id: string, + userId: string, + data: { + bangumiId?: number | null; + epNumber?: number | null; + scrapeStatus: string; + scrapedAt?: Date | null; + posterUrl?: string | null; + title?: string; + }, + ): Promise { + const [row] = await db + .update(mediaItems) + .set({ ...data, updatedAt: new Date() }) + .where(and(eq(mediaItems.id, id), eq(mediaItems.userId, userId))) + .returning(); + return row ?? null; + }, +}; + +// packages/dao/src/playback-progress.ts +import { and, eq } from "drizzle-orm"; +import { playbackProgress, type PlaybackProgress } from "@app/models"; +import { db } from "@app/db"; + +export const playbackProgressDao = { + async get(userId: string, mediaItemId: string): Promise { + const [row] = await db + .select() + .from(playbackProgress) + .where(and(eq(playbackProgress.userId, userId), eq(playbackProgress.mediaItemId, mediaItemId))) + .limit(1); + return row ?? null; + }, + async upsert(userId: string, mediaItemId: string, positionMs: number, durationMs: number): Promise { + const existing = await this.get(userId, mediaItemId); + if (existing) { + const [row] = await db + .update(playbackProgress) + .set({ positionMs, durationMs, updatedAt: new Date() }) + .where(eq(playbackProgress.id, existing.id)) + .returning(); + if (!row) throw new Error("Failed to update progress"); + return row; + } + const rows = await db + .insert(playbackProgress) + .values({ userId, mediaItemId, positionMs, durationMs }) + .returning(); + const row = rows[0]; + if (!row) throw new Error("Failed to insert progress"); + return row; + }, +}; + +// packages/dao/src/danmaku-cache.ts +import { and, eq, gt, or, isNull } from "drizzle-orm"; +import { danmakuCache, type DanmakuCacheRow } from "@app/models"; +import { db } from "@app/db"; + +export const danmakuCacheDao = { + async getValid(matchKey: string): Promise { + const now = new Date(); + const [row] = await db + .select() + .from(danmakuCache) + .where( + and( + eq(danmakuCache.matchKey, matchKey), + or(isNull(danmakuCache.expiresAt), gt(danmakuCache.expiresAt, now)), + ), + ) + .limit(1); + return row ?? null; + }, + async upsert(data: { + matchKey: string; + payload: string; + source: string; + expiresAt: Date | null; + }): Promise { + const existing = await db.select().from(danmakuCache).where(eq(danmakuCache.matchKey, data.matchKey)).limit(1); + if (existing[0]) { + const [row] = await db + .update(danmakuCache) + .set({ payload: data.payload, source: data.source, expiresAt: data.expiresAt }) + .where(eq(danmakuCache.id, existing[0].id)) + .returning(); + if (!row) throw new Error("Failed to update danmaku cache"); + return row; + } + const rows = await db.insert(danmakuCache).values(data).returning(); + const row = rows[0]; + if (!row) throw new Error("Failed to insert danmaku cache"); + return row; + }, +}; +``` + +`packages/dao/src/index.ts` 追加: + +```ts +export { mountDao } from "./mounts.js"; +export type { MountRow } from "./mounts.js"; +export { mediaItemDao } from "./media-items.js"; +export type { MediaItemRow } from "./media-items.js"; +export { playbackProgressDao } from "./playback-progress.js"; +export { danmakuCacheDao } from "./danmaku-cache.js"; +``` + +- [ ] **步骤 2:验证** + +```bash +yarn workspace @app/dao typecheck +yarn workspace @app/dao build +``` + +- [ ] **步骤 3:Commit** + +```bash +git add packages/dao +git commit -m "feat(dao): mount/media/progress/danmaku-cache daos" +``` + +--- + +## 任务 4:secret + WebDAV 客户端 + mount service + 单测 + +**文件:** +- 创建:`packages/trpc/src/services/secret.ts` +- 创建:`packages/trpc/src/services/webdav-client.ts` +- 创建:`packages/trpc/src/services/mount.service.ts` +- 创建:`packages/trpc/src/services/secret.test.ts` +- 创建:`packages/trpc/src/services/webdav-client.test.ts` +- 修改:`packages/trpc/package.json`(vitest) +- 修改:`packages/trpc/src/index.ts`(导出 mountService) + +- [ ] **步骤 1:写失败测试** + +```ts +// packages/trpc/src/services/secret.test.ts +import { describe, expect, it } from "vitest"; +import { decryptSecret, encryptSecret } from "./secret.js"; + +describe("secret", () => { + it("roundtrip", () => { + process.env.MEDIA_SECRET_KEY = "0".repeat(64); + const enc = encryptSecret("p@ss"); + expect(enc).not.toContain("p@ss"); + expect(decryptSecret(enc)).toBe("p@ss"); + }); + it("missing key throws", () => { + delete process.env.MEDIA_SECRET_KEY; + expect(() => encryptSecret("x")).toThrow(); + }); +}); + +// packages/trpc/src/services/webdav-client.test.ts +import { describe, expect, it } from "vitest"; +import { joinWebdavPath, isVideoFilename } from "./webdav-client.js"; + +describe("webdav helpers", () => { + it("joinWebdavPath", () => { + expect(joinWebdavPath("/root", "a/b.mkv")).toBe("/root/a/b.mkv"); + expect(joinWebdavPath("/", "x.mp4")).toBe("/x.mp4"); + }); + it("isVideoFilename", () => { + expect(isVideoFilename("E01.mkv")).toBe(true); + expect(isVideoFilename("readme.txt")).toBe(false); + }); +}); +``` + +- [ ] **步骤 2:安装 vitest 并确认失败** + +```bash +yarn workspace @app/trpc add -D vitest +# package.json: "test": "vitest run" +yarn workspace @app/trpc test +``` + +- [ ] **步骤 3:实现** + +```ts +// packages/trpc/src/services/secret.ts +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; + +function key(): Buffer { + const hex = process.env["MEDIA_SECRET_KEY"]; + if (!hex || hex.length !== 64) { + throw new Error("MEDIA_SECRET_KEY 必须是 64 位 hex(openssl rand -hex 32)"); + } + return Buffer.from(hex, "hex"); +} + +/** AES-256-GCM → base64(iv || tag || ciphertext) */ +export function encryptSecret(plain: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key(), iv); + const enc = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, enc]).toString("base64"); +} + +export function decryptSecret(payload: string): string { + const buf = Buffer.from(payload, "base64"); + const iv = buf.subarray(0, 12); + const tag = buf.subarray(12, 28); + const data = buf.subarray(28); + const decipher = createDecipheriv("aes-256-gcm", key(), iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(data), decipher.final()]).toString("utf8"); +} +``` + +```ts +// packages/trpc/src/services/webdav-client.ts +import { createClient, type WebDAVClient } from "webdav"; + +const VIDEO_EXT = new Set([ + ".mp4", ".mkv", ".webm", ".avi", ".mov", ".m4v", ".ts", ".flv", ".wmv", ".mpg", ".mpeg", +]); + +export function isVideoFilename(name: string): boolean { + const i = name.lastIndexOf("."); + if (i < 0) return false; + return VIDEO_EXT.has(name.slice(i).toLowerCase()); +} + +export function joinWebdavPath(root: string, rel: string): string { + const r = root.replace(/\/+$/, "") || ""; + const p = rel.replace(/^\/+/, ""); + if (!r || r === "/") return `/${p}`; + return `${r}/${p}`; +} + +export function createWebdav(opts: { + baseUrl: string; + username?: string | undefined; + password?: string | undefined; +}): WebDAVClient { + return createClient(opts.baseUrl, { + username: opts.username ?? "", + password: opts.password ?? "", + httpAgent: undefined, + }); +} + +export type DirEntry = { + filename: string; + basename: string; + type: "directory" | "file"; + size: number; +}; + +export async function listDirectory( + client: WebDAVClient, + absPath: string, +): Promise { + const items = await client.getDirectoryContents(absPath); + const arr = Array.isArray(items) ? items : items.data; + return arr.map((raw) => { + const item = raw as { + filename: string; + basename: string; + type: string; + size?: number; + }; + return { + filename: item.filename, + basename: item.basename, + type: item.type === "directory" ? "directory" : "file", + size: Number(item.size ?? 0), + }; + }); +} +``` + +```ts +// packages/trpc/src/services/mount.service.ts +import { TRPCError } from "@trpc/server"; +import { mountDao, type MountRow } from "@app/dao"; +import type { + MountCreateInput, + MountListDirInput, +} from "@app/types"; +import { decryptSecret, encryptSecret } from "./secret.js"; +import { createWebdav, joinWebdavPath, listDirectory, type DirEntry } from "./webdav-client.js"; + +export type MountPublic = { + id: string; + name: string; + type: string; + baseUrl: string; + username: string | null; + rootPath: string; + enabled: boolean; + createdAt: Date | null; + updatedAt: Date | null; +}; + +function toPublic(row: MountRow): MountPublic { + return { + id: row.id, + name: row.name, + type: row.type, + baseUrl: row.baseUrl, + username: row.username, + rootPath: row.rootPath, + enabled: row.enabled, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +async function withClient( + row: Pick, + fn: (client: ReturnType) => Promise, +): Promise { + const password = row.secretEnc ? decryptSecret(row.secretEnc) : ""; + const client = createWebdav({ + baseUrl: row.baseUrl, + username: row.username ?? undefined, + password, + }); + return fn(client); +} + +export const mountService = { + async list(userId: string): Promise<{ mounts: MountPublic[] }> { + const rows = await mountDao.listByUser(userId); + return { mounts: rows.map(toPublic) }; + }, + + async create(userId: string, input: MountCreateInput): Promise<{ mount: MountPublic }> { + try { + const row = await mountDao.create({ + userId, + name: input.name, + type: input.type, + baseUrl: input.baseUrl, + username: input.username ?? null, + secretEnc: input.password ? encryptSecret(input.password) : "", + rootPath: input.rootPath, + enabled: input.enabled, + }); + return { mount: toPublic(row) }; + } catch { + throw new TRPCError({ code: "CONFLICT", message: "挂载名称已存在或创建失败" }); + } + }, + + async update( + userId: string, + input: { + id: string; + name?: string | undefined; + baseUrl?: string | undefined; + username?: string | undefined; + password?: string | undefined; + rootPath?: string | undefined; + enabled?: boolean | undefined; + }, + ): Promise<{ mount: MountPublic }> { + const patch: Record = {}; + if (input.name !== undefined) patch["name"] = input.name; + if (input.baseUrl !== undefined) patch["baseUrl"] = input.baseUrl; + if (input.username !== undefined) patch["username"] = input.username; + if (input.password !== undefined) { + patch["secretEnc"] = input.password ? encryptSecret(input.password) : ""; + } + if (input.rootPath !== undefined) patch["rootPath"] = input.rootPath; + if (input.enabled !== undefined) patch["enabled"] = input.enabled; + const row = await mountDao.update(input.id, userId, patch); + if (!row) throw new TRPCError({ code: "NOT_FOUND", message: "挂载不存在" }); + return { mount: toPublic(row) }; + }, + + async delete(userId: string, id: string): Promise<{ success: true }> { + await mountDao.delete(id, userId); + return { success: true }; + }, + + async test(input: { + baseUrl: string; + username?: string | undefined; + password?: string | undefined; + rootPath: string; + }): Promise<{ ok: boolean; message: string }> { + try { + const client = createWebdav({ + baseUrl: input.baseUrl, + username: input.username, + password: input.password, + }); + await listDirectory(client, input.rootPath || "/"); + return { ok: true, message: "连接成功" }; + } catch (e) { + return { ok: false, message: e instanceof Error ? e.message : "连接失败" }; + } + }, + + async listDir(userId: string, input: MountListDirInput): Promise<{ entries: DirEntry[]; path: string }> { + const mount = await mountDao.getByIdForUser(input.mountId, userId); + if (!mount) throw new TRPCError({ code: "NOT_FOUND", message: "挂载不存在" }); + const abs = joinWebdavPath(mount.rootPath, input.path); + try { + const entries = await withClient(mount, (c) => listDirectory(c, abs)); + entries.sort((a, b) => { + if (a.type !== b.type) return a.type === "directory" ? -1 : 1; + return a.basename.localeCompare(b.basename); + }); + return { entries, path: input.path }; + } catch (e) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: e instanceof Error ? `读取目录失败: ${e.message}` : "读取目录失败", + }); + } + }, +}; +``` + +`packages/trpc/src/index.ts` 追加: + +```ts +export { mountService } from "./services/mount.service.js"; +export type { MountPublic } from "./services/mount.service.js"; +``` + +- [ ] **步骤 4:测试通过 + build** + +```bash +yarn workspace @app/trpc test +yarn workspace @app/trpc typecheck +yarn workspace @app/trpc build +``` + +- [ ] **步骤 5:Commit** + +```bash +git add packages/trpc +git commit -m "feat(trpc): webdav client + mount service with secret encryption" +``` + +--- + +## 任务 5:library 扫描 + Bangumi 刮削 + 单测 + +**文件:** +- 创建:`packages/trpc/src/services/scrape.service.ts` +- 创建:`packages/trpc/src/services/library.service.ts` +- 创建:`packages/trpc/src/services/scrape.service.test.ts` +- 修改:`packages/trpc/src/index.ts` + +- [ ] **步骤 1:失败测试(文件名解析)** + +```ts +// packages/trpc/src/services/scrape.service.test.ts +import { describe, expect, it } from "vitest"; +import { parseEpisodeFromFilename, buildSearchQuery } from "./scrape.service.js"; + +describe("parseEpisodeFromFilename", () => { + it("S01E02", () => { + expect(parseEpisodeFromFilename("Show.S01E02.1080p.mkv")).toBe(2); + }); + it("[Group] Title - 05 [1080p].mkv", () => { + expect(parseEpisodeFromFilename("[Group] Title - 05 [1080p].mkv")).toBe(5); + }); + it("无集数", () => { + expect(parseEpisodeFromFilename("Movie.mkv")).toBeNull(); + }); +}); + +describe("buildSearchQuery", () => { + it("去扩展名与字幕组前缀", () => { + expect(buildSearchQuery("[Sub] Bocchi the Rock! - 01.mkv")).toContain("Bocchi"); + expect(buildSearchQuery("[Sub] Bocchi the Rock! - 01.mkv")).not.toContain(".mkv"); + }); +}); +``` + +- [ ] **步骤 2:确认失败** + +```bash +yarn workspace @app/trpc test +``` + +- [ ] **步骤 3:实现 scrape + library** + +```ts +// packages/trpc/src/services/scrape.service.ts +import { mediaItemDao } from "@app/dao"; + +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; +} + +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(); +} + +export type BangumiSearchHit = { + id: number; + name: string; + nameCn: string; + image: string; + date: string | null; + summary: string; +}; + +function apiBase(): string { + return process.env["BANGUMI_API_BASE"] ?? "https://api.bgm.tv"; +} + +export async function bangumiSearch(q: string): Promise { + const url = `${apiBase()}/search/subject/${encodeURIComponent(q)}?type=2&responseSearch=subjects`; + const res = await fetch(url, { + headers: { "User-Agent": "dandanplay-web/0.1" }, + }); + if (!res.ok) throw new Error(`Bangumi HTTP ${res.status}`); + const data = (await res.json()) as { + list?: Array<{ + id: number; + name: string; + nameCn?: string; + date?: string | null; + summary?: string; + images?: { medium?: string; large?: string }; + }>; + }; + return (data.list ?? []).slice(0, 12).map((item) => ({ + id: item.id, + name: item.name, + nameCn: item.nameCn ?? item.name, + image: item.images?.medium ?? item.images?.large ?? "", + date: item.date ?? null, + summary: item.summary ?? "", + })); +} + +export const scrapeService = { + search: bangumiSearch, + + async bind(userId: string, mediaItemId: string, bangumiId: number): 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 仍成功,海报可后补 + } + await mediaItemDao.updateScrape(mediaItemId, userId, { + bangumiId, + scrapeStatus: "ok", + scrapedAt: new Date(), + posterUrl, + title, + epNumber: row.epNumber, + }); + return { success: true }; + }, + + /** 扫描后自动刮削:命中绑第一条,否则 unmatched */ + async autoScrape(userId: string, mediaItemId: string): Promise { + const row = await mediaItemDao.getByIdForUser(mediaItemId, userId); + if (!row || row.scrapeStatus === "ok") return; + const q = buildSearchQuery(row.rawName); + if (!q) { + await mediaItemDao.updateScrape(mediaItemId, userId, { + scrapeStatus: "unmatched", + scrapedAt: new Date(), + }); + return; + } + try { + const hits = await bangumiSearch(q); + const hit = hits[0]; + if (!hit) { + await mediaItemDao.updateScrape(mediaItemId, userId, { + scrapeStatus: "unmatched", + scrapedAt: new Date(), + }); + return; + } + await mediaItemDao.updateScrape(mediaItemId, userId, { + bangumiId: hit.id, + scrapeStatus: "ok", + scrapedAt: new Date(), + posterUrl: hit.image || null, + title: hit.nameCn || hit.name, + epNumber: parseEpisodeFromFilename(row.rawName), + }); + } catch { + await mediaItemDao.updateScrape(mediaItemId, userId, { + scrapeStatus: "failed", + scrapedAt: new Date(), + }); + } + }, +}; +``` + +```ts +// packages/trpc/src/services/library.service.ts +import { TRPCError } from "@trpc/server"; +import { mediaItemDao, mountDao, type MediaItemRow } from "@app/dao"; +import { decryptSecret } from "./secret.js"; +import { createWebdav, isVideoFilename, joinWebdavPath, listDirectory } from "./webdav-client.js"; +import { scrapeService } from "./scrape.service.js"; + +const MAX_SCAN_FILES = 2000; + +async function walkVideos( + userId: string, + mountId: string, + root: string, + rel: string, + budget: { left: number }, + out: Promise[], +): Promise { + if (budget.left <= 0) return; + const mount = await mountDao.getByIdForUser(mountId, userId); + if (!mount) return; + const client = createWebdav({ + baseUrl: mount.baseUrl, + username: mount.username ?? undefined, + password: mount.secretEnc ? decryptSecret(mount.secretEnc) : undefined, + }); + const abs = joinWebdavPath(root, rel); + let entries; + try { + entries = await listDirectory(client, abs); + } catch { + return; + } + for (const entry of entries) { + if (budget.left <= 0) return; + const childRel = rel === "/" || rel === "" ? `/${entry.basename}` : `${rel.replace(/\/$/, "")}/${entry.basename}`; + if (entry.type === "directory") { + await walkVideos(userId, mountId, root, childRel, budget, out); + continue; + } + if (!isVideoFilename(entry.basename)) continue; + budget.left -= 1; + const title = entry.basename.replace(/\.[a-z0-9]+$/i, ""); + out.push( + (async () => { + const item = await mediaItemDao.upsertFromScan({ + userId, + mountId, + path: childRel, + rawName: entry.basename, + title, + size: entry.size, + }); + await scrapeService.autoScrape(userId, item.id); + })(), + ); + } +} + +export const libraryService = { + async list( + userId: string, + params: { + limit: number; + offset: number; + scrapeStatus?: string | undefined; + q?: string | undefined; + }, + ) { + return mediaItemDao.list(userId, params); + }, + + async get(userId: string, id: string): Promise { + 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 }> { + 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: Promise[] = []; + await walkVideos(userId, mountId, mount.rootPath || "/", "/", budget, jobs); + await Promise.allSettled(jobs); + return { scanned: MAX_SCAN_FILES - budget.left, message: "扫描完成" }; + }, +}; +``` + +`packages/trpc/src/index.ts` 追加: + +```ts +export { libraryService } from "./services/library.service.js"; +export { scrapeService, parseEpisodeFromFilename, buildSearchQuery } from "./services/scrape.service.js"; +``` + +- [ ] **步骤 4:测试通过 + build** + +```bash +yarn workspace @app/trpc test +yarn workspace @app/trpc typecheck +yarn workspace @app/trpc build +``` + +- [ ] **步骤 5:Commit** + +```bash +git add packages/trpc +git commit -m "feat(trpc): library scan + bangumi scrape" +``` + +--- + +## 任务 6:playback + danmaku service + +**文件:** +- 创建:`packages/trpc/src/services/playback.service.ts` +- 创建:`packages/trpc/src/services/danmaku.service.ts` +- 修改:`packages/trpc/src/index.ts` + +- [ ] **步骤 1:实现** + +```ts +// packages/trpc/src/services/playback.service.ts +import { playbackProgressDao } from "@app/dao"; +import type { PlaybackReportInput } from "@app/types"; + +export const playbackService = { + async report(userId: string, input: PlaybackReportInput) { + const row = await playbackProgressDao.upsert( + userId, + input.mediaItemId, + input.positionMs, + input.durationMs, + ); + return { + mediaItemId: row.mediaItemId, + positionMs: row.positionMs, + durationMs: row.durationMs, + updatedAt: row.updatedAt, + }; + }, + async get(userId: string, mediaItemId: string) { + const row = await playbackProgressDao.get(userId, mediaItemId); + if (!row) return { mediaItemId, positionMs: 0, durationMs: 0, updatedAt: null }; + return { + mediaItemId: row.mediaItemId, + positionMs: row.positionMs, + durationMs: row.durationMs, + updatedAt: row.updatedAt, + }; + }, +}; +``` + +```ts +// packages/trpc/src/services/danmaku.service.ts +import { createHash } from "node:crypto"; +import { danmakuCacheDao, mediaItemDao, mountDao } from "@app/dao"; +import type { DanmakuFetchOutput } from "@app/types"; +import { decryptSecret } from "./secret.js"; +import { createWebdav } from "./webdav-client.js"; + +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +function openApiBase(): string { + return process.env["OPEN_DANMAKU_API_BASE"] ?? "https://api.dandanplay.net"; +} + +function matchKeyFor(path: string, size: number): string { + return createHash("sha256").update(`${path}:${size}`).digest("hex"); +} + +/** 取 WebDAV 文件 hash(首 64KiB + 尺寸),用于开放网络识别 */ +async function remoteSampleHash(userId: string, mediaId: string): Promise { + const item = await mediaItemDao.getByIdForUser(mediaId, userId); + if (!item?.mountId) return null; + const mount = await mountDao.getByIdForUser(item.mountId, userId); + if (!mount) return null; + const client = createWebdav({ + baseUrl: mount.baseUrl, + username: mount.username ?? undefined, + password: mount.secretEnc ? decryptSecret(mount.secretEnc) : undefined, + }); + try { + const abs = mount.rootPath.replace(/\/+$/, "") + item.path; + const stream = await client.createReadStream(abs, { start: 0, end: 65535 }); + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string)); + } + const sample = Buffer.concat(chunks); + return createHash("sha256") + .update(sample) + .update(String(item.size)) + .digest("hex") + .toUpperCase(); + } catch { + return null; + } +} + +async function fetchOpenNetworkXml(path: string, size: number, hash: string | null): Promise { + const base = openApiBase(); + try { + const identifyRes = await fetch(`${base}/api/v2/identify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + fileHash: hash ?? "", + fileSize: size, + fileName: path.split("/").pop() ?? path, + }), + }); + if (!identifyRes.ok) return null; + const identified = (await identifyRes.json()) as { + isMatched?: boolean; + matched?: boolean; + episodeId?: number; + }; + const episodeId = identified.episodeId; + if (!episodeId || !(identified.isMatched || identified.matched)) return null; + const commentRes = await fetch(`${base}/api/v2/comment/${episodeId}?withResponses=false`); + if (!commentRes.ok) return null; + const body = (await commentRes.json()) as { danmaku?: string; xml?: string }; + return body.danmaku ?? body.xml ?? null; + } catch { + return null; + } +} + +export const danmakuService = { + async fetch(userId: string, mediaItemId: string): Promise { + const item = await mediaItemDao.getByIdForUser(mediaItemId, userId); + if (!item) return { ok: false, source: "none", xml: "", message: "媒体不存在" }; + + const matchKey = matchKeyFor(item.path, item.size); + const cached = await danmakuCacheDao.getValid(matchKey); + if (cached) { + return { ok: true, source: "cache", xml: cached.payload }; + } + + const hash = await remoteSampleHash(userId, mediaItemId); + const xml = await fetchOpenNetworkXml(item.path, item.size, hash); + if (!xml) { + return { ok: true, source: "none", xml: "", message: "开放网络未匹配到弹幕" }; + } + await danmakuCacheDao.upsert({ + matchKey, + payload: xml, + source: "open-network", + expiresAt: new Date(Date.now() + CACHE_TTL_MS), + }); + return { ok: true, source: "open-network", xml }; + }, + + /** 本地 XML 由前端解析后直接喂播放器;此处仅确认媒体存在并记 size 日志约束 */ + async importMeta(userId: string, mediaItemId: string, byteSize: number): Promise<{ ok: true }> { + const item = await mediaItemDao.getByIdForUser(mediaItemId, userId); + if (!item) return Promise.reject(new Error("媒体不存在")); + if (byteSize > 20_000_000) return Promise.reject(new Error("XML 过大")); + return { ok: true }; + }, +}; +``` + +`packages/trpc/src/index.ts` 追加: + +```ts +export { playbackService } from "./services/playback.service.js"; +export { danmakuService } from "./services/danmaku.service.js"; +``` + +- [ ] **步骤 2:验证** + +```bash +yarn workspace @app/trpc typecheck +yarn workspace @app/trpc build +yarn workspace @app/trpc test +``` + +- [ ] **步骤 3:Commit** + +```bash +git add packages/trpc +git commit -m "feat(trpc): playback progress + danmaku open-network relay" +``` + +--- + +## 任务 7:protectedProcedure + media router + 挂到 appRouter + +**文件:** +- 创建:`packages/trpc/src/services/procedure.ts` +- 创建:`packages/trpc/src/router/media.router.ts` +- 修改:`packages/trpc/src/router/index.ts` +- 修改:`packages/trpc/src/index.ts` + +- [ ] **步骤 1:实现** + +```ts +// packages/trpc/src/services/procedure.ts +import { TRPCError } from "@trpc/server"; +import { t } from "../context.js"; +import { authService } from "./auth.service.js"; + +/** 需要登录;解析出 userId 注入 ctx。 */ +export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { + if (!ctx.sessionToken) { + throw new TRPCError({ code: "UNAUTHORIZED", message: "请先登录" }); + } + const user = await authService.getSessionUser(ctx.sessionToken); + if (!user) { + throw new TRPCError({ code: "UNAUTHORIZED", message: "请先登录" }); + } + return next({ + ctx: { + sessionToken: ctx.sessionToken, + setCookie: ctx.setCookie, + userId: user.id, + }, + }); +}); +``` + +```ts +// packages/trpc/src/router/media.router.ts +import { t } from "../context.js"; +import { danmakuSchemas, librarySchemas, mountSchemas, playbackSchemas, scrapeSchemas } from "@app/types"; +import { mountService } from "../services/mount.service.js"; +import { libraryService } from "../services/library.service.js"; +import { scrapeService } from "../services/scrape.service.js"; +import { playbackService } from "../services/playback.service.js"; +import { danmakuService } from "../services/danmaku.service.js"; +import { protectedProcedure } from "../services/procedure.js"; + +export const mediaRouter = t.router({ + mountList: protectedProcedure.input(mountSchemas.list).query(({ ctx }) => mountService.list(ctx.userId)), + mountCreate: protectedProcedure + .input(mountSchemas.create) + .mutation(({ ctx, input }) => mountService.create(ctx.userId, input)), + mountUpdate: protectedProcedure + .input(mountSchemas.update) + .mutation(({ ctx, input }) => mountService.update(ctx.userId, input)), + mountDelete: protectedProcedure + .input(mountSchemas.delete) + .mutation(({ ctx, input }) => mountService.delete(ctx.userId, input.id)), + mountTest: protectedProcedure.input(mountSchemas.test).mutation(({ input }) => mountService.test(input)), + mountListDir: protectedProcedure + .input(mountSchemas.listDir) + .query(({ ctx, input }) => mountService.listDir(ctx.userId, input)), + + libraryList: protectedProcedure + .input(librarySchemas.list) + .query(({ ctx, input }) => libraryService.list(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)), + + scrapeSearch: protectedProcedure.input(scrapeSchemas.search).query(({ input }) => + scrapeService.search(input.q), + ), + scrapeBind: protectedProcedure + .input(scrapeSchemas.bind) + .mutation(({ ctx, input }) => scrapeService.bind(ctx.userId, input.mediaItemId, input.bangumiId)), + scrapeRescrape: protectedProcedure + .input(scrapeSchemas.rescrape) + .mutation(({ ctx, input }) => scrapeService.autoScrape(ctx.userId, input.mediaItemId).then(() => ({ success: true as const }))), + + playbackReport: protectedProcedure + .input(playbackSchemas.report) + .mutation(({ ctx, input }) => playbackService.report(ctx.userId, input)), + playbackGet: protectedProcedure + .input(playbackSchemas.get) + .query(({ ctx, input }) => playbackService.get(ctx.userId, input.mediaItemId)), + + danmakuFetch: protectedProcedure + .input(danmakuSchemas.fetch) + .query(({ ctx, input }) => danmakuService.fetch(ctx.userId, input.mediaItemId)), + danmakuImportMeta: protectedProcedure + .input(danmakuSchemas.importMeta) + .mutation(({ ctx, input }) => + danmakuService.importMeta(ctx.userId, input.mediaItemId, input.byteSize), + ), + danmakuGetSettings: protectedProcedure.input(danmakuSchemas.getSettings).query(() => ({ + enabled: true, + opacity: 0.8, + density: 1, + })), + danmakuSaveSettings: protectedProcedure + .input(danmakuSchemas.settings) + .mutation(({ input }) => input), +}); +``` + +`packages/trpc/src/router/index.ts` 在 `appRouter` 增加: + +```ts +import { mediaRouter } from "./media.router.js"; +// ... +export const appRouter = t.router({ + user: userRouter, + auth: authRouter, + media: mediaRouter, +}); +``` + +`packages/trpc/src/index.ts` 追加: + +```ts +export { mediaRouter } from "./router/media.router.js"; +``` + +- [ ] **步骤 2:验证** + +```bash +yarn workspace @app/trpc typecheck +yarn workspace @app/trpc build +``` + +- [ ] **步骤 3:Commit** + +```bash +git add packages/trpc +git commit -m "feat(trpc): media router mount/library/scrape/playback/danmaku" +``` + +--- + +## 任务 8:stream Range 代理(Astro API) + +**文件:** +- 创建:`app/web/src/pages/api/stream.ts` + +- [ ] **步骤 1:实现** + +```ts +// app/web/src/pages/api/stream.ts +import type { APIRoute } from "astro"; +import { COOKIE_SESSION } from "@app/types"; +import { authService } from "@app/trpc"; +// 复用 service:通过动态 import 拿到 mount 解密与 webdav 打开文件的能力 +// 为避免把 webdav 细节写进 web,在 trpc 导出 stream helper: +import { openRemoteFileStream } from "@app/trpc"; + +export const prerender = false; + +export const GET: APIRoute = async ({ request, cookies, url }) => { + const token = cookies.get(COOKIE_SESSION)?.value; + const user = await authService.getSessionUser(token); + if (!user) { + return new Response("Unauthorized", { status: 401 }); + } + const mediaItemId = url.searchParams.get("id"); + if (!mediaItemId) return new Response("id required", { status: 400 }); + + try { + const result = await openRemoteFileStream(user.id, mediaItemId, request.headers.get("range")); + return new Response(result.body, { + status: result.status, + headers: result.headers, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : "stream failed"; + return new Response(msg, { status: 502 }); + } +}; +``` + +在 `packages/trpc/src/services/stream.service.ts` 补充(任务 8 一并创建并 export): + +```ts +// packages/trpc/src/services/stream.service.ts +import { mediaItemDao, mountDao } from "@app/dao"; +import { decryptSecret } from "./secret.js"; +import { createWebdav } from "./webdav-client.js"; + +export async function openRemoteFileStream( + userId: string, + mediaItemId: string, + rangeHeader: string | null, +): Promise<{ status: number; headers: Headers; body: ReadableStream | null }> { + const item = await mediaItemDao.getByIdForUser(mediaItemId, userId); + if (!item || !item.mountId) throw new Error("媒体或挂载不存在"); + const mount = await mountDao.getByIdForUser(item.mountId, userId); + if (!mount) throw new Error("挂载不存在"); + + 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 headers = new Headers(); + headers.set("Content-Type", item.mime || "application/octet-stream"); + headers.set("Accept-Ranges", "bytes"); + headers.set("Cache-Control", "private, max-age=0"); + + let status = 200; + let start = 0; + let end = item.size > 0 ? item.size - 1 : 0; + + if (rangeHeader) { + const m = /bytes=(\d*)-(\d*)/.exec(rangeHeader); + if (m) { + if (m[1]) start = Number(m[1]); + if (m[2]) end = Number(m[2]); + status = 206; + headers.set("Content-Range", `bytes ${start}-${end}/${item.size}`); + headers.set("Content-Length", String(end - start + 1)); + } + } else if (item.size > 0) { + headers.set("Content-Length", String(item.size)); + } + + const nodeStream = await client.createReadStream(abs, { + start, + end: item.size > 0 ? end : undefined, + }); + + const body = new ReadableStream({ + start(controller) { + nodeStream.on("data", (chunk: Buffer | string) => { + controller.enqueue(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + nodeStream.on("end", () => controller.close()); + nodeStream.on("error", (err: unknown) => controller.error(err)); + }, + cancel() { + nodeStream.destroy(); + }, + }); + + return { status, headers, body }; +} +``` + +`packages/trpc/src/index.ts` 追加: + +```ts +export { openRemoteFileStream } from "./services/stream.service.js"; +``` + +- [ ] **步骤 2:验证** + +```bash +yarn workspace @app/trpc typecheck && yarn workspace @app/trpc build +yarn workspace @app/web typecheck +``` + +- [ ] **步骤 3:Commit** + +```bash +git add packages/trpc app/web/src/pages/api/stream.ts +git commit -m "feat(web): authenticated range stream proxy" +``` + +--- + +## 任务 9:middleware 保护新路由 + i18n 文案 + +**文件:** +- 修改:`app/web/src/middleware.ts` +- 修改:`packages/i18n/src/messages/zh-CN.ts` +- 修改:`packages/i18n/src/messages/en.ts` + +- [ ] **步骤 1:middleware** + +```ts +const PROTECTED_BASES = [ + "/dashboard", + "/settings", + "/library", + "/browse", + "/watch", + "/mounts", + "/danmaku", +] as const; +``` + +- [ ] **步骤 2:i18n key(zh-CN 权威,en 同构)** + +在 `zh-CN.ts` 增加(en.ts 对应英文): + +```ts +"nav.library": "媒体库", +"nav.browse": "文件浏览", +"nav.watch": "播放", +"nav.mounts": "挂载管理", +"nav.danmaku": "弹幕设置", + +"library.title": "媒体库", +"library.scan": "扫描挂载", +"library.unmatched": "未匹配", +"library.continue": "继续播放", + +"browse.title": "文件浏览", +"browse.play": "播放", +"browse.addLibrary": "扫描入库", + +"mounts.title": "挂载管理", +"mounts.add": "添加 WebDAV", +"mounts.test": "测试连接", +"mounts.openlistHint": "OpenList 请填写其 WebDAV 地址(如 https://host/dav)", + +"watch.danmakuOn": "弹幕开", +"watch.importXml": "导入本地 XML", +"watch.noDanmaku": "暂无在线弹幕", + +"danmaku.title": "弹幕设置", +"danmaku.opacity": "不透明度", +"danmaku.density": "密度", +"danmaku.enabled": "启用弹幕", +``` + +- [ ] **步骤 3:验证** + +```bash +yarn workspace @app/i18n typecheck && yarn workspace @app/i18n build +yarn workspace @app/web typecheck +``` + +- [ ] **步骤 4:Commit** + +```bash +git add app/web/src/middleware.ts packages/i18n +git commit -m "feat(web): protect media routes + i18n keys" +``` + +--- + +## 任务 10:AppShell + 挂载管理 UI + +**文件:** +- 修改:`app/web/package.json`(如需 `clsx`/`tailwind-merge` 已有则跳过) +- 创建:`app/web/src/components/AppShell.tsx` +- 创建:`app/web/src/components/pages/MountsBody.tsx` +- 创建:`app/web/src/views/mounts.astro` +- 创建:`app/web/src/pages/[locale]/mounts.astro` + +- [ ] **步骤 1:AppShell(shadcn dashboard 侧栏)** + +```tsx +// app/web/src/components/AppShell.tsx +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"]; + labels: Record; + children: ReactNode; +}) { + const base = `/${props.locale}`; + return ( +
+ +
{props.children}
+
+ ); +} +``` + +- [ ] **步骤 2:MountsBody + 视图** + +```tsx +// app/web/src/components/pages/MountsBody.tsx +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState } from "react"; +import { createTRPCReactClient } from "~/lib/trpc"; +import { trpc } from "~/lib/trpc"; +import { Button } from "@app/ui"; +import { Input } from "@app/ui"; + +function MountsInner(props: { locale: string }) { + const utils = trpc.useUtils(); + const list = trpc.media.mountList.useQuery(); + const create = trpc.media.mountCreate.useMutation({ + onSuccess: () => utils.media.mountList.invalidate(), + }); + 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: "/", + }); + + return ( +
+

挂载管理

+

+ OpenList 请填写其 WebDAV 地址;凭据仅保存在服务端。 +

+
{ + 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} +

+ )} +
+ +
    + {(list.data?.mounts ?? []).map((m) => ( +
  • +
    +
    {m.name}
    +
    {m.baseUrl}
    +
    +
    + +
    +
  • + ))} + {list.data?.mounts.length === 0 && ( +
  • 尚未添加挂载
  • + )} +
+
+ ); +} + +const client = createTRPCReactClient(); +const qc = new QueryClient(); + +export function MountsBody(props: { locale: string }) { + return ( + + + + + + ); +} +``` + +注意:若 `@app/ui` 未导出 `Input`/`Button` 的 `variant`,以 `packages/ui/src/button.tsx`、`input.tsx` 实际导出为准调整 import(读源码后改)。 + +视图 `views/mounts.astro` 复制 `views/dashboard.astro` 结构,`client:load` 换 `MountsBody`;页面 `pages/[locale]/mounts.astro` 同 `dashboard.astro` 模式。 + +- [ ] **步骤 3:验证** + +```bash +yarn workspace @app/web typecheck +yarn workspace @app/web lint +yarn build:web +``` + +- [ ] **步骤 4:Commit** + +```bash +git add app/web +git commit -m "feat(web): app shell + mounts admin page" +``` + +--- + +## 任务 11:文件浏览 UI + +**文件:** +- 创建:`app/web/src/components/pages/BrowseBody.tsx` +- 创建:`app/web/src/views/browse.astro` +- 创建:`app/web/src/pages/[locale]/browse.astro` + +- [ ] **步骤 1:BrowseBody** + +```tsx +// app/web/src/components/pages/BrowseBody.tsx +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState } from "react"; +import { createTRPCReactClient, trpc } from "~/lib/trpc"; +import { Button } from "@app/ui"; + +function BrowseInner() { + const mounts = trpc.media.mountList.useQuery(); + const [mountId, setMountId] = useState(""); + const [path, setPath] = useState("/"); + const [crumbs, setCrumbs] = useState([]); + + const dir = trpc.media.mountListDir.useQuery( + { mountId, path }, + { enabled: Boolean(mountId) }, + ); + const scan = trpc.media.libraryScan.useMutation(); + + function goUp() { + const next = crumbs.slice(0, -1); + setCrumbs(next); + setPath(next.length ? `/${next.join("/")}/`.replace(/\/+/g, "/") : "/"); + } + + function enter(name: string) { + const next = [...crumbs, name]; + setCrumbs(next); + setPath(`/${next.join("/")}`); + } + + return ( +
+
+

文件浏览

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

{scan.data.message}

} +
+ + {crumbs.map((c, i) => ( + + /{" "} + + + ))} + {crumbs.length > 0 && ( + + )} +
+
    + {(dir.data?.entries ?? []).map((e) => ( +
  • + {e.type === "directory" ? ( + + ) : ( + + {e.basename} + + )} + {/\.(mp4|mkv|webm|avi|mov|m4v)$/i.test(e.basename) && ( + + 扫描后可在媒体库播放 + + )} +
  • + ))} + {dir.data?.entries.length === 0 && ( +
  • 空目录或未选择挂载
  • + )} +
+
+ ); +} + +const client = createTRPCReactClient(); +const qc = new QueryClient(); + +export function BrowseBody() { + return ( + + + + + + ); +} +``` + +说明:直接「点视频播放」需要先确保该 path 已入库;简化为 **扫描后从媒体库进 /watch**,或 Browse 对单文件调用「扫描后 get by path」。任务 11 增强:点击视频时若未入库,先触发对该 mount 的 scan(或 path 级 upsert)。为保持计划可执行,采用: + +```tsx +// 点击视频:调用 libraryScan(全量)后跳转 library;二期做 path 级 upsert +``` + +更优:在 library.service 增加 `ensurePath`(scan 单文件)。在任务 11 的实现步骤中补充 service: + +```ts +// library.service.ts 增加 +async ensurePath(userId: string, mountId: string, path: string) { + const mount = await mountDao.getByIdForUser(mountId, userId); + if (!mount) throw new TRPCError({ code: "NOT_FOUND", message: "挂载不存在" }); + const client = createWebdav({ + baseUrl: mount.baseUrl, + username: mount.username ?? undefined, + password: mount.secretEnc ? decryptSecret(mount.secretEnc) : undefined, + }); + const abs = joinWebdavPath(mount.rootPath, path); + // webdav getFileUpload / stat + const stat = await client.stat(abs); + const basename = path.split("/").pop() ?? path; + const item = await mediaItemDao.upsertFromScan({ + userId, + mountId, + path, + rawName: basename, + title: basename.replace(/\.[a-z0-9]+$/i, ""), + size: Number((stat as { size?: number }).size ?? 0), + }); + await scrapeService.autoScrape(userId, item.id); + return item; +} +``` + +并在 `media.router` 增加 `libraryEnsurePath` mutation;Browse 视频行「播放」→ `ensurePath` → `router.push(\`/${locale}/watch?id=${item.id}\`)`。 + +- [ ] **步骤 2:视图 + 路由同 mounts 模式** + +- [ ] **步骤 3:验证 typecheck/lint/build 并 Commit** + +```bash +yarn workspace @app/web typecheck +yarn build:web +git add app/web packages/trpc +git commit -m "feat(web): browse page with ensurePath play" +``` + +--- + +## 任务 12:媒体库 UI + Bangumi 手动匹配 + +**文件:** +- 创建:`app/web/src/components/pages/LibraryBody.tsx` +- 创建:`app/web/src/views/library.astro` +- 创建:`app/web/src/pages/[locale]/library.astro` + +- [ ] **步骤 1:LibraryBody** + +```tsx +// app/web/src/components/pages/LibraryBody.tsx +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState } from "react"; +import { createTRPCReactClient, trpc } from "~/lib/trpc"; +import { Button, Input } from "@app/ui"; + +function LibraryInner(props: { locale: string }) { + const [filter, setFilter] = useState<"" | "unmatched" | "ok">(""); + const [q, setQ] = useState(""); + const list = trpc.media.libraryList.useQuery({ + limit: 24, + offset: 0, + scrapeStatus: filter || undefined, + q: q || undefined, + }); + const progressList = list.data?.rows ?? []; + const [matchId, setMatchId] = useState(null); + const [searchQ, setSearchQ] = useState(""); + const search = trpc.media.scrapeSearch.useQuery( + { q: searchQ }, + { enabled: searchQ.length > 1 && matchId !== null }, + ); + const bind = trpc.media.scrapeBind.useMutation({ + onSuccess: () => { + setMatchId(null); + list.refetch(); + }, + }); + + return ( +
+
+

媒体库

+
+ setQ(e.target.value)} + /> + +
+
+ +
+ {progressList.map((item) => ( +
+
+ {item.posterUrl ? ( + // biome-ignore lint/performance/noImgElement: 海报外链 + + ) : ( + "无封面" + )} +
+
+
+ {item.title} +
+
+ + {item.scrapeStatus !== "ok" && ( + + )} +
+
+
+ ))} + {progressList.length === 0 && ( +

+ 暂无媒体,请先在文件浏览中扫描挂载。 +

+ )} +
+ + {matchId && ( +
+
+

手动匹配 Bangumi

+ setSearchQ(e.target.value)} + /> +
    + {(search.data ?? []).map((hit) => ( +
  • + {hit.nameCn || hit.name} + +
  • + ))} +
+ +
+
+ )} +
+ ); +} + +const client = createTRPCReactClient(); +const qc = new QueryClient(); + +export function LibraryBody(props: { locale: string }) { + return ( + + + + + + ); +} +``` + +- [ ] **步骤 2:视图 + 路由;typecheck/build;Commit** + +```bash +yarn workspace @app/web typecheck && yarn build:web +git add app/web +git commit -m "feat(web): library grid + bangumi manual bind" +``` + +--- + +## 任务 13:播放页 ArtPlayer + 弹幕 + 进度 + +**文件:** +- 修改:`app/web/package.json`(`yarn workspace @app/web add artplayer`) +- 创建:`app/web/src/components/pages/WatchBody.tsx` +- 创建:`app/web/src/views/watch.astro` +- 创建:`app/web/src/pages/[locale]/watch.astro` + +- [ ] **步骤 1:安装 artplayer** + +```bash +yarn workspace @app/web add artplayer +``` + +- [ ] **步骤 2:WatchBody** + +```tsx +// app/web/src/components/pages/WatchBody.tsx +import Artplayer from "artplayer"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useEffect, useRef, useState } from "react"; +import { createTRPCReactClient, trpc } from "~/lib/trpc"; +import { Button } from "@app/ui"; + +function WatchInner(props: { locale: string; mediaItemId: string }) { + const videoRef = useRef(null); + const artRef = useRef(null); + const [localXml, setLocalXml] = useState(""); + const [danmakuOn, setDanmakuOn] = useState(true); + + const detail = trpc.media.libraryGet.useQuery({ id: props.mediaItemId }); + const progress = trpc.media.playbackGet.useQuery({ mediaItemId: props.mediaItemId }); + const openDanmaku = trpc.media.danmakuFetch.useQuery( + { mediaItemId: props.mediaItemId }, + { enabled: danmakuOn }, + ); + const report = trpc.media.playbackReport.useMutation(); + + useEffect(() => { + if (!videoRef.current || !detail.data) return; + const startMs = progress.data?.positionMs ?? 0; + const art = new Artplayer({ + container: videoRef.current, + url: `/api/stream?id=${encodeURIComponent(props.mediaItemId)}`, + title: detail.data.title, + volume: 0.7, + autoplay: false, + pip: true, + setting: true, + playbackRate: true, + aspectRatio: true, + fullscreen: true, + theme: "#3b82f6", + }); + artRef.current = art; + + art.on("video:loadedmetadata", () => { + if (startMs > 0 && art.duration > startMs / 1000) { + art.currentTime = startMs / 1000; + } + }); + + let timer: ReturnType | null = null; + art.on("play", () => { + timer = setInterval(() => { + report.mutate({ + mediaItemId: props.mediaItemId, + positionMs: Math.floor(art.currentTime * 1000), + durationMs: Math.floor((art.duration || 0) * 1000), + }); + }, 5000); + }); + art.on("pause", () => { + if (timer) clearInterval(timer); + report.mutate({ + mediaItemId: props.mediaItemId, + positionMs: Math.floor(art.currentTime * 1000), + durationMs: Math.floor((art.duration || 0) * 1000), + }); + }); + art.on("destroy", () => { + if (timer) clearInterval(timer); + }); + + return () => { + art.destroy(false); + artRef.current = null; + }; + }, [detail.data?.id, props.mediaItemId, progress.data?.positionMs, report]); + + // 弹幕:优先本地 XML,其次开放网络;用 art.plugins 或自绘简单滚动层 + const xmlSource = localXml || (danmakuOn ? (openDanmaku.data?.xml ?? "") : ""); + + useEffect(() => { + const art = artRef.current; + if (!art || !xmlSource) return; + // 简易弹幕:挂在 container 上的 overlay canvas 可后续换成 artplayer-plugin-danmaku + // 首版:将 XML 暴露到 window 供调试,并在 UI 显示条数 + const count = (xmlSource.match(/d p=/g) ?? []).length; + const el = videoRef.current?.querySelector("[data-danmaku-count]"); + if (el) el.textContent = `弹幕 ${count} 条`; + }, [xmlSource]); + + function onImportXml(file: File) { + const reader = new FileReader(); + reader.onload = () => { + setLocalXml(String(reader.result ?? "")); + }; + reader.readAsText(file); + } + + return ( +
+
+

{detail.data?.title ?? "加载中…"}

+
+ + + +
+
+
+
+ + {openDanmaku.data?.source === "none" + ? "暂无在线弹幕,可导入 XML" + : openDanmaku.data?.source === "cache" + ? "弹幕(缓存)" + : openDanmaku.data?.source === "open-network" + ? "弹幕(开放网络)" + : "弹幕加载中…"} + + 进度约每 5 秒保存 +
+
+ ); +} + +const client = createTRPCReactClient(); +const qc = new QueryClient(); + +export function WatchBody(props: { locale: string; mediaItemId: string }) { + return ( + + + + + + ); +} +``` + +`views/watch.astro` 用 `Astro.url.searchParams.get("id")` 传入 `mediaItemId`;无 id 则回 library。 + +**弹幕叠加说明(实现时必做):** 首版至少实现 **简易弹幕渲染**(读取 XML 中 `d` 节点的 `p` 属性时间与文本,用绝对定位 `div` 动画),放入 `app/web/src/lib/danmaku-render.ts`,在 `WatchInner` 中:`xmlSource` 变化时启动 `requestAnimationFrame` 循环,容器为播放器 wrapper。禁止只显示计数却宣称完成弹幕。 + +```ts +// app/web/src/lib/danmaku-render.ts +export type DanmakuItem = { time: number; text: string; mode: number }; + +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 time = Number(t); + if (!Number.isFinite(time)) continue; + out.push({ time, text: n.textContent ?? "", mode: Number(mode) || 1 }); + } + return out.sort((a, b) => a.time - b.time); +} + +export function startDanmakuLoop( + container: HTMLElement, + getItems: () => DanmakuItem[], + getCurrentTime: () => number, +): () => void { + const layer = document.createElement("div"); + layer.className = "absolute inset-0 overflow-hidden pointer-events-none"; + layer.style.position = "absolute"; + container.style.position = container.style.position || "relative"; + container.appendChild(layer); + const active: { el: HTMLDivElement; start: number; y: number }[] = []; + let raf = 0; + let cursor = 0; + const tick = () => { + const now = getCurrentTime(); + const items = getItems(); + while (cursor < items.length && items[cursor]!.time <= now) { + const it = items[cursor]!; + 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 y = Math.random() * (container.clientHeight - 40); + el.style.top = `${y}px`; + layer.appendChild(el); + active.push({ el, start: performance.now(), y }); + cursor += 1; + } + const t = performance.now(); + for (let i = active.length - 1; i >= 0; i -= 1) { + const a = active[i]!; + const elapsed = (t - a.start) / 1000; + const x = container.clientWidth - elapsed * 160; + 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); + layer.remove(); + }; +} +``` + +在 WatchBody 中 `useEffect` 连接 `parseDanmakuXml` + `startDanmakuLoop`(`getCurrentTime: () => artRef.current?.currentTime ?? 0`)。 + +- [ ] **步骤 3:typecheck/build(artplayer 类型若报错则 `skipLibCheck` 或官方 d.ts)** + +```bash +yarn workspace @app/web typecheck +yarn build:web +``` + +- [ ] **步骤 4:Commit** + +```bash +git add app/web +git commit -m "feat(web): artplayer watch page with danmaku overlay and progress" +``` + +--- + +## 任务 14:弹幕设置页 + 全局验证 + +**文件:** +- 创建:`app/web/src/components/pages/DanmakuSettingsBody.tsx` +- 创建:`app/web/src/views/danmaku.astro` +- 创建:`app/web/src/pages/[locale]/danmaku.astro` + +- [ ] **步骤 1:设置页** + +```tsx +// app/web/src/components/pages/DanmakuSettingsBody.tsx +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState } from "react"; +import { createTRPCReactClient, trpc } from "~/lib/trpc"; +import { Button } from "@app/ui"; + +function Inner() { + const save = trpc.media.danmakuSaveSettings.useMutation(); + const [enabled, setEnabled] = useState(true); + const [opacity, setOpacity] = useState(0.8); + const [density, setDensity] = useState(1); + + return ( +
+

弹幕设置

+ + + + + {save.isSuccess &&

已保存(本地会话)

} +

+ 本地 XML 导入在播放页操作;此处保存默认开关与显示参数。 +

+
+ ); +} + +const client = createTRPCReactClient(); +const qc = new QueryClient(); + +export function DanmakuSettingsBody() { + return ( + + + + + + ); +} +``` + +说明:设置目前回显 mutation 成功;若需持久化,在 `media_items` 外增加 `user_settings` 表——**本计划以 localStorage 键 `media-danmaku-settings` 持久化**,mutation 仅校验 Zod。实现时在 `Inner` 的 save 里: + +```ts +localStorage.setItem("media-danmaku-settings", JSON.stringify({ enabled, opacity, density })); +``` + +并在 `WatchInner` 初始化时读取该键。 + +- [ ] **步骤 2:全仓验证** + +```bash +yarn typecheck +yarn lint +yarn build +``` + +预期:全部 PASS。若有 Biome 格式问题:`yarn format` 后重跑 lint。 + +- [ ] **步骤 3:手动冒烟清单(本地)** + +1. `cp .env.example .env` 并设置 `MEDIA_SECRET_KEY` +2. `yarn db:push` +3. `yarn dev:web` +4. 注册 → 添加 WebDAV 挂载 → 测试连接 → 浏览/扫描 → 媒体库 → 播放(或用本地 WebDAV 如 rclone serve webdav) +5. 无 WebDAV 时:至少验证登录后 `/zh-CN/mounts` 可打开、表单校验生效 + +- [ ] **步骤 4:Commit** + +```bash +git add app/web packages/i18n +git commit -m "feat(web): danmaku settings page + watch local prefs" +``` + +--- + +## 规格覆盖自检 + +| 规格需求 | 任务 | +|----------|------| +| 多用户 auth | 沿用已有 + middleware | +| 挂载 CRUD + 测试 + 列目录 | 4, 7, 10 | +| 凭据加密不回传 | 4 secret + toPublic | +| 扫描入库 | 5, 11 | +| Bangumi 自动+手动刮削 | 5, 7, 12 | +| 进度 | 6, 7, 13 | +| 开放弹幕中继 + 缓存 | 6, 7, 13 | +| 本地 XML 导入 | 13 | +| 弹幕设置 | 14 | +| ArtPlayer | 13 | +| stream Range | 8 | +| shadcn dashboard UI | 10–14 AppShell | +| i18n 路由保护 | 9 | +| 测试 | 1, 4, 5 vitest | +| 全仓验证 | 14 | + +**占位符扫描:** 无「待定/TODO」;开放弹幕 API 字段以 `openApiBase()` 环境变量可配置,实现对接时若官方响应字段不同,仅改 `fetchOpenNetworkXml` 一处。 + +**类型一致性:** `protectedProcedure` 的 `ctx.userId`、`mountService`/`libraryService` 方法名与 router 一致;`DanmakuFetchOutput` 来自 `@app/types`。 + +--- + +## 执行交接 + +计划已保存到 `docs/superpowers/plans/2026-09-25-dandanplay-web.md`。 + +两种执行方式: + +1. **子代理驱动(推荐)** — 每个任务调度一个新的子代理,任务间进行审查,快速迭代 +2. **内联执行** — 在当前会话中使用 executing-plans 执行任务,批量执行并设有检查点 + +**选哪种方式?** +