# 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 执行任务,批量执行并设有检查点 **选哪种方式?**