96 KiB
Web 版弹弹play(媒体中心)实现计划
面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(
- [ ])语法来跟踪进度。
目标: 在 本产品仓 产品化全栈上实现多用户 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 追加):
# 媒体挂载密钥(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:写入失败的测试
// 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 并跑测确认失败
yarn workspace @app/types add -D vitest
# package.json scripts: "test": "vitest run"
yarn workspace @app/types test
预期:FAIL(模块不存在)。
- 步骤 3:实现 schema
// 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 追加:
export * from "./media-schemas.js";
packages/types/package.json scripts 增加:
"test": "vitest run"
- 步骤 4:跑测通过
yarn workspace @app/types test
yarn workspace @app/types typecheck
yarn workspace @app/types build
预期:PASS。
- 步骤 5:Commit
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:写入表定义
// 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;
// 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 追加导出(模式与现有一致):
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
yarn workspace @app/models typecheck
yarn workspace @app/models build
yarn db:push
预期:本地 file:local.db 出现新表;若无 .env,先 cp .env.example .env。
- 步骤 3:Commit
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)
// 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)));
},
};
// 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 追加:
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:验证
yarn workspace @app/dao typecheck
yarn workspace @app/dao build
- 步骤 3:Commit
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:写失败测试
// 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 并确认失败
yarn workspace @app/trpc add -D vitest
# package.json: "test": "vitest run"
yarn workspace @app/trpc test
- 步骤 3:实现
// 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");
}
// 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),
};
});
}
// 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 追加:
export { mountService } from "./services/mount.service.js";
export type { MountPublic } from "./services/mount.service.js";
- 步骤 4:测试通过 + build
yarn workspace @app/trpc test
yarn workspace @app/trpc typecheck
yarn workspace @app/trpc build
- 步骤 5:Commit
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:失败测试(文件名解析)
// 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:确认失败
yarn workspace @app/trpc test
- 步骤 3:实现 scrape + library
// 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(),
});
}
},
};
// 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 追加:
export { libraryService } from "./services/library.service.js";
export { scrapeService, parseEpisodeFromFilename, buildSearchQuery } from "./services/scrape.service.js";
- 步骤 4:测试通过 + build
yarn workspace @app/trpc test
yarn workspace @app/trpc typecheck
yarn workspace @app/trpc build
- 步骤 5:Commit
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:实现
// 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,
};
},
};
// 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 追加:
export { playbackService } from "./services/playback.service.js";
export { danmakuService } from "./services/danmaku.service.js";
- 步骤 2:验证
yarn workspace @app/trpc typecheck
yarn workspace @app/trpc build
yarn workspace @app/trpc test
- 步骤 3:Commit
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:实现
// 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,
},
});
});
// 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 增加:
import { mediaRouter } from "./media.router.js";
// ...
export const appRouter = t.router({
user: userRouter,
auth: authRouter,
media: mediaRouter,
});
packages/trpc/src/index.ts 追加:
export { mediaRouter } from "./router/media.router.js";
- 步骤 2:验证
yarn workspace @app/trpc typecheck
yarn workspace @app/trpc build
- 步骤 3:Commit
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:实现
// 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):
// 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 追加:
export { openRemoteFileStream } from "./services/stream.service.js";
- 步骤 2:验证
yarn workspace @app/trpc typecheck && yarn workspace @app/trpc build
yarn workspace @app/web typecheck
- 步骤 3:Commit
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
const PROTECTED_BASES = [
"/dashboard",
"/settings",
"/library",
"/browse",
"/watch",
"/mounts",
"/danmaku",
] as const;
- 步骤 2:i18n key(zh-CN 权威,en 同构)
在 zh-CN.ts 增加(en.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:验证
yarn workspace @app/i18n typecheck && yarn workspace @app/i18n build
yarn workspace @app/web typecheck
- 步骤 4:Commit
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 侧栏)
// 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"] ?? "帆幕"}
</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 + 视图
// 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:验证
yarn workspace @app/web typecheck
yarn workspace @app/web lint
yarn build:web
- 步骤 4:Commit
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
// 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)。为保持计划可执行,采用:
// 点击视频:调用 libraryScan(全量)后跳转 library;二期做 path 级 upsert
更优:在 library.service 增加 ensurePath(scan 单文件)。在任务 11 的实现步骤中补充 service:
// 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
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
// 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
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
yarn workspace @app/web add artplayer
- 步骤 2:WatchBody
// 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。禁止只显示计数却宣称完成弹幕。
// 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)
yarn workspace @app/web typecheck
yarn build:web
- 步骤 4:Commit
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:设置页
// 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 里:
localStorage.setItem("media-danmaku-settings", JSON.stringify({ enabled, opacity, density }));
并在 WatchInner 初始化时读取该键。
- 步骤 2:全仓验证
yarn typecheck
yarn lint
yarn build
预期:全部 PASS。若有 Biome 格式问题:yarn format 后重跑 lint。
- 步骤 3:手动冒烟清单(本地)
cp .env.example .env并设置MEDIA_SECRET_KEYyarn db:pushyarn dev:web- 注册 → 添加 WebDAV 挂载 → 测试连接 → 浏览/扫描 → 媒体库 → 播放(或用本地 WebDAV 如 rclone serve webdav)
- 无 WebDAV 时:至少验证登录后
/zh-CN/mounts可打开、表单校验生效
- 步骤 4:Commit
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。
两种执行方式:
- 子代理驱动(推荐) — 每个任务调度一个新的子代理,任务间进行审查,快速迭代
- 内联执行 — 在当前会话中使用 executing-plans 执行任务,批量执行并设有检查点
选哪种方式?