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