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

96 KiB
Raw Blame History

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:手动冒烟清单(本地)
  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
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 执行任务,批量执行并设有检查点

选哪种方式?