app-template/docs/superpowers/plans/2026-09-25-dandanplay-web.md

2780 lines
96 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<typeof mountSchemas.create>;
export type MountListDirInput = z.infer<typeof mountSchemas.listDir>;
export type LibraryListInput = z.infer<typeof librarySchemas.list>;
export type PlaybackReportInput = z.infer<typeof playbackSchemas.report>;
export type ScrapeSearchInput = z.infer<typeof scrapeSchemas.search>;
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<MountRow[]> {
return db.select().from(mounts).where(eq(mounts.userId, userId)).orderBy(desc(mounts.createdAt));
},
async getByIdForUser(id: string, userId: string): Promise<MountRow | null> {
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<MountRow> {
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<Omit<NewMount, "id" | "userId">>): Promise<MountRow | null> {
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<void> {
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<number>`count(*)` }).from(mediaItems).where(where);
return { rows, total: Number(countRow?.["count"] ?? 0) };
},
async getByIdForUser(id: string, userId: string): Promise<MediaItemRow | null> {
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<MediaItemRow | null> {
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<MediaItemRow> {
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<MediaItemRow | null> {
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<PlaybackProgress | null> {
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<PlaybackProgress> {
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<DanmakuCacheRow | null> {
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<DanmakuCacheRow> {
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<DirEntry[]> {
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<T>(
row: Pick<MountRow, "baseUrl" | "username" | "secretEnc">,
fn: (client: ReturnType<typeof createWebdav>) => Promise<T>,
): Promise<T> {
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<string, unknown> = {};
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<BangumiSearchHit[]> {
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<void> {
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<void>[],
): Promise<void> {
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<MediaItemRow> {
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<void>[] = [];
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<string | null> {
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<string | null> {
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<DanmakuFetchOutput> {
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<string, string>;
children: ReactNode;
}) {
const base = `/${props.locale}`;
return (
<div className="flex min-h-screen bg-background text-foreground">
<aside className="hidden w-56 shrink-0 border-r border-border bg-muted/40 md:flex md:flex-col md:p-4">
<div className="mb-6 px-2 text-sm font-semibold tracking-wide">
{props.labels["meta.brand"] ?? "弹弹 Web"}
</div>
<nav className="flex flex-col gap-1" aria-label="sidebar">
{NAV.map((item) => (
<a
key={item.id}
href={`${base}/${item.href}`}
className={cn(
"rounded-md px-3 py-2 text-sm transition-colors",
props.active === item.id
? "bg-background font-medium shadow-sm"
: "text-muted-foreground hover:bg-background/60",
)}
>
{props.labels[item.hrefKey] ?? item.href}
</a>
))}
</nav>
</aside>
<main className="flex min-w-0 flex-1 flex-col p-4 md:p-6">{props.children}</main>
</div>
);
}
```
- [ ] **步骤 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 (
<div className="space-y-6">
<h1 className="text-xl font-semibold">挂载管理</h1>
<p className="text-sm text-muted-foreground">
OpenList 请填写其 WebDAV 地址;凭据仅保存在服务端。
</p>
<form
className="grid gap-3 rounded-lg border border-border bg-card p-4 md:grid-cols-2"
onSubmit={(e) => {
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,
});
}}
>
<Input
placeholder="名称,如 OpenList"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
/>
<Input
placeholder="https://example.com/dav"
value={form.baseUrl}
onChange={(e) => setForm({ ...form, baseUrl: e.target.value })}
required
/>
<Input
placeholder="用户名(可选)"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
/>
<Input
type="password"
placeholder="密码 / 令牌"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
<Input
placeholder="根路径 /"
value={form.rootPath}
onChange={(e) => setForm({ ...form, rootPath: e.target.value })}
/>
<div className="flex gap-2">
<Button type="submit" disabled={create.isPending}>
保存
</Button>
<Button
type="button"
variant="outline"
onClick={() =>
test.mutate({
baseUrl: form.baseUrl,
username: form.username || undefined,
password: form.password || undefined,
rootPath: form.rootPath || "/",
})
}
>
测试连接
</Button>
</div>
{test.data && (
<p className={test.data.ok ? "text-green-600" : "text-red-600"}>
{test.data.message}
</p>
)}
</form>
<ul className="divide-y divide-border rounded-lg border border-border">
{(list.data?.mounts ?? []).map((m) => (
<li key={m.id} className="flex items-center justify-between gap-3 p-3">
<div>
<div className="font-medium">{m.name}</div>
<div className="text-xs text-muted-foreground break-all">{m.baseUrl}</div>
</div>
<div className="flex gap-2">
<Button
variant="destructive"
size="sm"
onClick={() => del.mutate({ id: m.id })}
>
删除
</Button>
</div>
</li>
))}
{list.data?.mounts.length === 0 && (
<li className="p-4 text-sm text-muted-foreground">尚未添加挂载</li>
)}
</ul>
</div>
);
}
const client = createTRPCReactClient();
const qc = new QueryClient();
export function MountsBody(props: { locale: string }) {
return (
<QueryClientProvider client={qc}>
<trpc.Provider client={client} queryClient={qc}>
<MountsInner locale={props.locale} />
</trpc.Provider>
</QueryClientProvider>
);
}
```
注意:若 `@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<string>("");
const [path, setPath] = useState("/");
const [crumbs, setCrumbs] = useState<string[]>([]);
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 (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-semibold">文件浏览</h1>
<select
className="h-9 rounded-md border border-border bg-background px-2 text-sm"
value={mountId}
onChange={(e) => {
setMountId(e.target.value);
setCrumbs([]);
setPath("/");
}}
>
<option value="">选择挂载…</option>
{(mounts.data?.mounts ?? []).map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
{mountId && (
<Button size="sm" variant="outline" onClick={() => scan.mutate({ mountId })}>
扫描入库
</Button>
)}
</div>
{scan.data && <p className="text-sm text-muted-foreground">{scan.data.message}</p>}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<button type="button" onClick={() => { setCrumbs([]); setPath("/"); }}>
root
</button>
{crumbs.map((c, i) => (
<span key={`${c}-${i}`}>
/{" "}
<button
type="button"
onClick={() => {
const next = crumbs.slice(0, i + 1);
setCrumbs(next);
setPath(`/${next.join("/")}`);
}}
>
{c}
</button>
</span>
))}
{crumbs.length > 0 && (
<button type="button" onClick={goUp}>../</button>
)}
</div>
<ul className="divide-y divide-border rounded-lg border border-border">
{(dir.data?.entries ?? []).map((e) => (
<li key={e.filename} className="flex items-center justify-between p-3 text-sm">
{e.type === "directory" ? (
<button type="button" className="text-left font-medium" onClick={() => enter(e.basename)}>
📁 {e.basename}
</button>
) : (
<span className={/\.(mp4|mkv|webm|avi|mov|m4v)$/i.test(e.basename) ? "" : "text-muted-foreground"}>
{e.basename}
</span>
)}
{/\.(mp4|mkv|webm|avi|mov|m4v)$/i.test(e.basename) && (
<span className="text-xs text-muted-foreground">
扫描后可在媒体库播放
</span>
)}
</li>
))}
{dir.data?.entries.length === 0 && (
<li className="p-4 text-sm text-muted-foreground">空目录或未选择挂载</li>
)}
</ul>
</div>
);
}
const client = createTRPCReactClient();
const qc = new QueryClient();
export function BrowseBody() {
return (
<QueryClientProvider client={qc}>
<trpc.Provider client={client} queryClient={qc}>
<BrowseInner />
</trpc.Provider>
</QueryClientProvider>
);
}
```
说明:直接「点视频播放」需要先确保该 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<string | null>(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 (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-semibold">媒体库</h1>
<div className="ml-auto flex gap-2">
<Input
className="w-40"
placeholder="搜索标题"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<select
className="h-9 rounded-md border border-border bg-background px-2 text-sm"
value={filter}
onChange={(e) => setFilter(e.target.value as typeof filter)}
>
<option value="">全部</option>
<option value="ok">已刮削</option>
<option value="unmatched">未匹配</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-6">
{progressList.map((item) => (
<div key={item.id} className="overflow-hidden rounded-lg border border-border bg-card">
<div className="aspect-[3/4] bg-muted flex items-center justify-center text-xs text-muted-foreground">
{item.posterUrl ? (
// biome-ignore lint/performance/noImgElement: 海报外链
<img src={item.posterUrl} alt="" className="h-full w-full object-cover" />
) : (
"无封面"
)}
</div>
<div className="p-2">
<div className="truncate text-sm font-medium" title={item.title}>
{item.title}
</div>
<div className="mt-1 flex gap-1">
<Button size="sm" variant="secondary" asChild={false} onClick={() => {
window.location.href = `/${props.locale}/watch?id=${item.id}`;
}}>
播放
</Button>
{item.scrapeStatus !== "ok" && (
<Button size="sm" variant="outline" onClick={() => setMatchId(item.id)}>
匹配
</Button>
)}
</div>
</div>
</div>
))}
{progressList.length === 0 && (
<p className="col-span-full text-sm text-muted-foreground">
暂无媒体,请先在文件浏览中扫描挂载。
</p>
)}
</div>
{matchId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-lg rounded-lg border border-border bg-background p-4 space-y-3">
<h2 className="font-medium">手动匹配 Bangumi</h2>
<Input
placeholder="搜索作品名"
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
/>
<ul className="max-h-64 divide-y divide-border overflow-auto rounded border border-border">
{(search.data ?? []).map((hit) => (
<li key={hit.id} className="flex items-center justify-between p-2 text-sm">
<span>{hit.nameCn || hit.name}</span>
<Button size="sm" onClick={() => bind.mutate({ mediaItemId: matchId, bangumiId: hit.id })}>
绑定
</Button>
</li>
))}
</ul>
<Button variant="outline" onClick={() => setMatchId(null)}>
取消
</Button>
</div>
</div>
)}
</div>
);
}
const client = createTRPCReactClient();
const qc = new QueryClient();
export function LibraryBody(props: { locale: string }) {
return (
<QueryClientProvider client={qc}>
<trpc.Provider client={client} queryClient={qc}>
<LibraryInner locale={props.locale} />
</trpc.Provider>
</QueryClientProvider>
);
}
```
- [ ] **步骤 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<HTMLDivElement>(null);
const artRef = useRef<Artplayer | null>(null);
const [localXml, setLocalXml] = useState<string>("");
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<typeof setInterval> | 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 (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-semibold">{detail.data?.title ?? "加载中…"}</h1>
<div className="ml-auto flex gap-2">
<Button size="sm" variant={danmakuOn ? "default" : "outline"} onClick={() => setDanmakuOn((v) => !v)}>
{danmakuOn ? "弹幕开" : "弹幕关"}
</Button>
<label className="inline-flex h-9 cursor-pointer items-center rounded-md border border-border px-3 text-sm">
导入本地 XML
<input
type="file"
accept=".xml,text/xml"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) onImportXml(f);
}}
/>
</label>
<Button size="sm" variant="ghost" onClick={() => window.location.href = `/${props.locale}/library`}>
返回媒体库
</Button>
</div>
</div>
<div ref={videoRef} className="aspect-video w-full overflow-hidden rounded-lg bg-black" data-danmaku-count="" />
<div className="flex justify-between text-sm text-muted-foreground">
<span data-danmaku-count>
{openDanmaku.data?.source === "none"
? "暂无在线弹幕,可导入 XML"
: openDanmaku.data?.source === "cache"
? "弹幕(缓存)"
: openDanmaku.data?.source === "open-network"
? "弹幕(开放网络)"
: "弹幕加载中…"}
</span>
<span>进度约每 5 秒保存</span>
</div>
</div>
);
}
const client = createTRPCReactClient();
const qc = new QueryClient();
export function WatchBody(props: { locale: string; mediaItemId: string }) {
return (
<QueryClientProvider client={qc}>
<trpc.Provider client={client} queryClient={qc}>
<WatchInner locale={props.locale} mediaItemId={props.mediaItemId} />
</trpc.Provider>
</QueryClientProvider>
);
}
```
`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 (
<div className="max-w-md space-y-4">
<h1 className="text-xl font-semibold">弹幕设置</h1>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
启用弹幕
</label>
<label className="block text-sm">
不透明度 {(opacity * 100).toFixed(0)}%
<input
type="range"
min={0}
max={1}
step={0.05}
value={opacity}
onChange={(e) => setOpacity(Number(e.target.value))}
className="w-full"
/>
</label>
<label className="block text-sm">
密度 {density}x
<input
type="range"
min={0.1}
max={3}
step={0.1}
value={density}
onChange={(e) => setDensity(Number(e.target.value))}
className="w-full"
/>
</label>
<Button onClick={() => save.mutate({ enabled, opacity, density })}>
保存
</Button>
{save.isSuccess && <p className="text-sm text-green-600">已保存(本地会话)</p>}
<p className="text-xs text-muted-foreground">
本地 XML 导入在播放页操作;此处保存默认开关与显示参数。
</p>
</div>
);
}
const client = createTRPCReactClient();
const qc = new QueryClient();
export function DanmakuSettingsBody() {
return (
<QueryClientProvider client={qc}>
<trpc.Provider client={client} queryClient={qc}>
<Inner />
</trpc.Provider>
</QueryClientProvider>
);
}
```
说明:设置目前回显 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 执行任务,批量执行并设有检查点
**选哪种方式?**