Compare commits

...

30 Commits

Author SHA1 Message Date
noelorin a7873891f2 fix: caveman-review findings (danmaku match, SSRF, progress ownership, i18n errors, paging, QueryClient) 2026-09-26 00:49:08 +08:00
noelorin e65acd1e9c fix(models,dao): media_items unique key includes mountId 2026-09-25 12:15:50 +08:00
noelorin 3af8ebead7 fix(trpc): stream Content-Type fallback by extension 2026-09-25 12:15:50 +08:00
noelorin b4ac0b6281 chore(env): document MEDIA_SECRET_KEY and open-danmaku/bangumi env 2026-09-25 12:15:50 +08:00
noelorin d9ebcb10bd fix(trpc): open danmaku official API match/comment + 16MB MD5 2026-09-25 12:15:50 +08:00
noelorin 728a576a4f fix(web): danmaku density range 0.1-1 per product ruling 2026-09-25 11:30:44 +08:00
noelorin f96afbc4b5 feat(web): danmaku settings page + watch local prefs 2026-09-25 05:29:28 +08:00
noelorin 8ecfee686c fix(web): watch error states and unload progress flush 2026-09-25 05:15:56 +08:00
noelorin 426a65e448 feat(web): artplayer watch page with danmaku overlay and progress 2026-09-25 05:04:44 +08:00
noelorin c7c6e30a56 feat(web): library grid + bangumi manual bind 2026-09-25 04:51:16 +08:00
noelorin 61fe323bf8 fix(web): stable breadcrumb keys without array index 2026-09-25 04:36:27 +08:00
noelorin 99fd2a58e7 style: biome format browse/ensurePath changes 2026-09-25 04:36:10 +08:00
noelorin 5a291a9127 feat(web): browse page with ensurePath play 2026-09-25 04:35:58 +08:00
noelorin 113c7f2848 fix(web): hide template side-rail on media pages 2026-09-25 04:07:35 +08:00
noelorin c2692a0ceb feat(web): app shell + mounts admin page 2026-09-25 03:29:56 +08:00
noelorin 2c6bc8818d feat(web): protect media routes + i18n keys 2026-09-25 03:23:10 +08:00
noelorin 94e923486e fix(trpc): stream backpressure via pull-driven pause and RFC range edge cases 2026-09-25 03:17:42 +08:00
noelorin cfddf5bbe1 feat(web): authenticated range stream proxy 2026-09-25 03:12:49 +08:00
noelorin f009d2cce4 feat(trpc): media router mount/library/scrape/playback/danmaku 2026-09-25 03:06:12 +08:00
noelorin 8839763f24 feat(trpc): playback progress + danmaku open-network relay 2026-09-25 03:00:16 +08:00
noelorin c37f9dcf62 fix(trpc): bound scrape concurrency and surface DB errors 2026-09-25 02:56:23 +08:00
noelorin 94d389b855 feat(trpc): library scan + bangumi scrape 2026-09-25 02:52:08 +08:00
noelorin cdc40d61b1 feat(trpc): webdav client + mount service with secret encryption 2026-09-25 02:48:17 +08:00
noelorin 2f5000cc97 feat(dao): mount/media/progress/danmaku-cache daos 2026-09-25 02:44:01 +08:00
noelorin f94aabe8a5 feat(models): mounts/media_items/playback_progress/danmaku_cache 2026-09-25 02:40:32 +08:00
noelorin 3143a9c7d0 fix(types): exclude test files from build output 2026-09-25 02:36:59 +08:00
noelorin c0779dffdf feat(types): media mount/library/playback/scrape/danmaku schemas 2026-09-25 02:35:52 +08:00
noelorin 3db8a61207 chore: ignore .worktrees and .superpowers 2026-09-25 02:29:19 +08:00
noelorin cf71444abb docs: dandanplay-web implementation plan 2026-09-25 01:50:31 +08:00
noelorin ddc89858af docs: Web 版弹弹play 设计规格(方案 A + Bangumi 刮削) 2026-09-25 01:44:43 +08:00
65 changed files with 6805 additions and 44 deletions

View File

@ -8,3 +8,19 @@ TURSO_DATABASE_URL=file:local.db
# SEO 规范源(canonical / sitemap / og:url 使用的真实域名,生产必填)
# PUBLIC_SITE_URL=https://tool.example.com
# 媒体挂载凭据加密密钥:64 位 hex(openssl rand -hex 32)
MEDIA_SECRET_KEY=
# 弹弹play 开放弹幕网络(https://doc.dandanplay.com/open)
# 未配置 AppId/AppSecret 时,弹幕接口返回 source=none「未配置开放网络」
OPEN_DANMAKU_APP_ID=
OPEN_DANMAKU_APP_SECRET=
# 可选:自定义开放弹幕 API 地址(默认 https://api.dandanplay.net)
# OPEN_DANMAKU_API_BASE=https://api.dandanplay.net
# 可选:Bangumi API 地址(默认 https://api.bgm.tv)
# BANGUMI_API_BASE=https://api.bgm.tv
# WebDAV 挂载威胁模型:仅允许 http(s);拒绝 localhost/127.*、169.254.*(云 metadata)。
# 局域网私网(192.168.* 等)允许,便于本机 OpenList/群晖;公网部署请自行加出站策略。

5
.gitignore vendored
View File

@ -26,4 +26,7 @@ local.db
storybook-static/
**/storybook-static/
.DS_Store
.DS_Store
.superpowers/
.worktrees/
worktrees/

View File

@ -24,6 +24,7 @@
"@tanstack/react-query": "^5.75.0",
"@trpc/client": "^11.19.0",
"@trpc/react-query": "^11.19.0",
"artplayer": "^5.2.2",
"astro": "^7.3.3",
"gsap": "^3.15.0",
"lenis": "^1.3.26",

View File

@ -0,0 +1,44 @@
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"] | "watch";
labels: Record<string, string>;
children: ReactNode;
}) {
const base = `/${props.locale}`;
return (
<div className="flex min-h-screen bg-background text-foreground">
<aside className="hidden w-56 shrink-0 border-r border-border bg-muted/40 md:flex md:flex-col md:p-4">
<div className="mb-6 px-2 text-sm font-semibold tracking-wide">
{props.labels["meta.brand"] ?? "弹弹 Web"}
</div>
<nav className="flex flex-col gap-1" aria-label="sidebar">
{NAV.map((item) => (
<a
key={item.id}
href={`${base}/${item.href}`}
className={cn(
"rounded-md px-3 py-2 text-sm transition-colors",
props.active === item.id
? "bg-background font-medium shadow-sm"
: "text-muted-foreground hover:bg-background/60",
)}
>
{props.labels[item.hrefKey] ?? item.href}
</a>
))}
</nav>
</aside>
<main className="flex min-w-0 flex-1 flex-col p-4 md:p-6">{props.children}</main>
</div>
);
}

View File

@ -0,0 +1,196 @@
import type { Locale } from "@app/i18n";
import { t } from "@app/i18n";
import { isVideoFilename } from "@app/types";
import { Button } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { createTRPCReactClient, trpc } from "~/lib/trpc";
function pathOf(segments: string[]): string {
return segments.length > 0 ? `/${segments.join("/")}` : "/";
}
function fileRelPath(dirPath: string, basename: string): string {
return dirPath === "/" ? `/${basename}` : `${dirPath.replace(/\/+$/, "")}/${basename}`;
}
function BrowseInner(props: { locale: Locale }) {
const { locale } = props;
const mounts = trpc.media.mountList.useQuery();
const [mountId, setMountId] = useState("");
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();
const ensure = trpc.media.libraryEnsurePath.useMutation();
function goRoot() {
setCrumbs([]);
setPath("/");
}
function goUp() {
const next = crumbs.slice(0, -1);
setCrumbs(next);
setPath(pathOf(next));
}
function goToCrumb(index: number) {
const next = crumbs.slice(0, index + 1);
setCrumbs(next);
setPath(pathOf(next));
}
function enter(name: string) {
const next = [...crumbs, name];
setCrumbs(next);
setPath(pathOf(next));
}
async function playFile(basename: string) {
if (!mountId) return;
try {
const item = await ensure.mutateAsync({ mountId, path: fileRelPath(path, basename) });
window.location.href = `/${locale}/watch?id=${item.id}`;
} catch {
// error surfaced via ensure.error
}
}
const mountList = mounts.data?.mounts ?? [];
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-semibold">{t(locale, "browse.title")}</h1>
<select
className="h-9 rounded-md border border-border bg-background px-2 text-sm"
value={mountId}
disabled={mounts.isPending || mountList.length === 0}
onChange={(e) => {
setMountId(e.target.value);
setCrumbs([]);
setPath("/");
scan.reset();
ensure.reset();
}}
>
<option value="">
{mountList.length === 0
? t(locale, "browse.mountsEmpty")
: t(locale, "browse.selectMount")}
</option>
{mountList.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
{mountId && (
<Button
size="sm"
variant="outline"
disabled={scan.isPending}
onClick={() => scan.mutate({ mountId })}
>
{t(locale, "browse.addLibrary")}
</Button>
)}
</div>
{scan.data && (
<p className="text-sm text-muted-foreground">
{t(locale, "browse.scanDone", { count: scan.data.scanned })}
</p>
)}
{scan.isError && (
<p className="text-sm text-red-600">{t(locale, "browse.scanError")}</p>
)}
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<button type="button" onClick={goRoot}>
{t(locale, "browse.root")}
</button>
{crumbs.map((c, i) => (
<span key={crumbs.slice(0, i + 1).join("/")}>
{" / "}
<button type="button" onClick={() => goToCrumb(i)}>
{c}
</button>
</span>
))}
{crumbs.length > 0 && (
<button type="button" onClick={goUp}>
{t(locale, "browse.up")}
</button>
)}
</div>
{mountId && dir.isPending && (
<p className="text-sm text-muted-foreground">{t(locale, "browse.loading")}</p>
)}
{mountId && dir.isError && (
<p className="text-sm text-red-600">{t(locale, "browse.loadError")}</p>
)}
{ensure.isError && (
<p className="text-sm text-red-600">{t(locale, "browse.playError")}</p>
)}
<ul className="divide-y divide-border rounded-lg border border-border">
{(dir.data?.entries ?? []).map((e) => {
const isVideo = e.type === "file" && isVideoFilename(e.basename);
return (
<li
key={e.filename}
className="flex items-center justify-between gap-3 p-3 text-sm"
>
{e.type === "directory" ? (
<button
type="button"
className="text-left font-medium"
onClick={() => enter(e.basename)}
>
{e.basename}
</button>
) : (
<span className={isVideo ? "font-medium" : "text-muted-foreground"}>
{e.basename}
</span>
)}
{isVideo && (
<Button
size="sm"
variant="secondary"
disabled={ensure.isPending}
onClick={() => playFile(e.basename)}
>
{t(locale, "browse.play")}
</Button>
)}
</li>
);
})}
{mountId && dir.data?.entries.length === 0 && (
<li className="p-4 text-sm text-muted-foreground">
{t(locale, "browse.empty")}
</li>
)}
{!mountId && (
<li className="p-4 text-sm text-muted-foreground">
{t(locale, "browse.empty")}
</li>
)}
</ul>
</div>
);
}
const client = createTRPCReactClient();
export function BrowseBody(props: { locale: Locale }) {
const [qc] = useState(() => new QueryClient());
return (
<QueryClientProvider client={qc}>
<trpc.Provider client={client} queryClient={qc}>
<BrowseInner locale={props.locale} />
</trpc.Provider>
</QueryClientProvider>
);
}

View File

@ -0,0 +1,108 @@
import type { Locale } from "@app/i18n";
import { t } from "@app/i18n";
import { Button } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { createTRPCReactClient, trpc } from "~/lib/trpc";
const DANMAKU_SETTINGS_KEY = "media-danmaku-settings";
type DanmakuSettings = { enabled: boolean; opacity: number; density: number };
const FALLBACK: DanmakuSettings = { enabled: true, opacity: 0.8, density: 1 };
function readSettings(): DanmakuSettings {
try {
const raw = localStorage.getItem(DANMAKU_SETTINGS_KEY);
if (!raw) return FALLBACK;
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) return FALLBACK;
const rec = parsed as Record<string, unknown>;
const enabled = typeof rec["enabled"] === "boolean" ? rec["enabled"] : FALLBACK.enabled;
const opacityRaw = typeof rec["opacity"] === "number" ? rec["opacity"] : FALLBACK.opacity;
const densityRaw = typeof rec["density"] === "number" ? rec["density"] : FALLBACK.density;
return {
enabled,
opacity: Math.min(1, Math.max(0, opacityRaw)),
density: Math.min(1, Math.max(0.1, densityRaw)),
};
} catch {
return FALLBACK;
}
}
function Inner(props: { locale: Locale }) {
const { locale } = props;
const save = trpc.media.danmakuSaveSettings.useMutation();
const [enabled, setEnabled] = useState(FALLBACK.enabled);
const [opacity, setOpacity] = useState(FALLBACK.opacity);
const [density, setDensity] = useState(FALLBACK.density);
useEffect(() => {
const prefs = readSettings();
setEnabled(prefs.enabled);
setOpacity(prefs.opacity);
setDensity(prefs.density);
}, []);
const onSave = () => {
const next = { enabled, opacity, density };
localStorage.setItem(DANMAKU_SETTINGS_KEY, JSON.stringify(next));
save.mutate(next);
};
return (
<div className="max-w-md space-y-4">
<h1 className="text-xl font-semibold">{t(locale, "danmaku.title")}</h1>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
{t(locale, "danmaku.enabled")}
</label>
<label className="block text-sm">
{t(locale, "danmaku.opacityValue", { value: (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">
{t(locale, "danmaku.densityValue", { value: density })}
<input
type="range"
min={0.1}
max={1}
step={0.1}
value={density}
onChange={(e) => setDensity(Number(e.target.value))}
className="w-full"
/>
</label>
<Button onClick={onSave}>{t(locale, "danmaku.save")}</Button>
{save.isSuccess && (
<p className="text-sm text-green-600">{t(locale, "danmaku.saved")}</p>
)}
<p className="text-xs text-muted-foreground">{t(locale, "danmaku.localImportHint")}</p>
</div>
);
}
const client = createTRPCReactClient();
export function DanmakuSettingsBody(props: { locale: Locale }) {
const [qc] = useState(() => new QueryClient());
return (
<QueryClientProvider client={qc}>
<trpc.Provider client={client} queryClient={qc}>
<Inner locale={props.locale} />
</trpc.Provider>
</QueryClientProvider>
);
}

View File

@ -0,0 +1,179 @@
import type { Locale } from "@app/i18n";
import { t } from "@app/i18n";
import { Button, Input } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { createTRPCReactClient, trpc } from "~/lib/trpc";
function LibraryInner(props: { locale: Locale }) {
const { locale } = props;
const [filter, setFilter] = useState<"" | "unmatched" | "ok">("");
const [q, setQ] = useState("");
const [offset, setOffset] = useState(0);
const pageLimit = 24;
const list = trpc.media.libraryList.useQuery({
limit: pageLimit,
offset,
scrapeStatus: filter || undefined,
q: q || undefined,
});
const progressList = list.data?.rows ?? [];
const total = list.data?.total ?? 0;
const hasMore = offset + progressList.length < total;
const resetPaging = () => setOffset(0);
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">{t(locale, "library.title")}</h1>
<div className="ml-auto flex gap-2">
<Input
className="w-40"
placeholder={t(locale, "library.searchPlaceholder")}
value={q}
onChange={(e) => {
setQ(e.target.value);
resetPaging();
}}
/>
<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);
resetPaging();
}}
>
<option value="">{t(locale, "library.filterAll")}</option>
<option value="ok">{t(locale, "library.filterOk")}</option>
<option value="unmatched">{t(locale, "library.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 ? (
<img
src={item.posterUrl}
alt=""
className="h-full w-full object-cover"
/>
) : (
t(locale, "library.noPoster")
)}
</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"
onClick={() => {
window.location.href = `/${locale}/watch?id=${item.id}`;
}}
>
{t(locale, "library.play")}
</Button>
{item.scrapeStatus !== "ok" && (
<Button
size="sm"
variant="outline"
onClick={() => setMatchId(item.id)}
>
{t(locale, "library.match")}
</Button>
)}
</div>
</div>
</div>
))}
{progressList.length === 0 && !list.isPending && (
<p className="col-span-full text-sm text-muted-foreground">
{t(locale, "library.empty")}
</p>
)}
</div>
{hasMore && (
<div className="flex justify-center">
<Button
variant="outline"
disabled={list.isPending}
onClick={() => setOffset((o) => o + pageLimit)}
>
{t(locale, "library.loadMore")}
</Button>
</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">{t(locale, "library.matchTitle")}</h2>
<Input
placeholder={t(locale, "library.searchWork")}
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,
})
}
>
{t(locale, "library.bind")}
</Button>
</li>
))}
</ul>
<Button variant="outline" onClick={() => setMatchId(null)}>
{t(locale, "library.cancel")}
</Button>
</div>
</div>
)}
</div>
);
}
const client = createTRPCReactClient();
export function LibraryBody(props: { locale: Locale }) {
const [qc] = useState(() => new QueryClient());
return (
<QueryClientProvider client={qc}>
<trpc.Provider client={client} queryClient={qc}>
<LibraryInner locale={props.locale} />
</trpc.Provider>
</QueryClientProvider>
);
}

View File

@ -0,0 +1,142 @@
import type { Locale } from "@app/i18n";
import { t } from "@app/i18n";
import { Button, Input } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { createTRPCReactClient, trpc } from "~/lib/trpc";
function MountsInner(props: { locale: Locale }) {
const { locale } = props;
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">{t(locale, "mounts.title")}</h1>
<p className="text-sm text-muted-foreground">
{t(locale, "mounts.openlistHint")} {t(locale, "mounts.credentialHint")}
</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={t(locale, "mounts.namePlaceholder")}
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
/>
<Input
placeholder={t(locale, "mounts.baseUrlPlaceholder")}
value={form.baseUrl}
onChange={(e) => setForm({ ...form, baseUrl: e.target.value })}
required
/>
<Input
placeholder={t(locale, "mounts.usernamePlaceholder")}
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
/>
<Input
type="password"
placeholder={t(locale, "mounts.passwordPlaceholder")}
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
<Input
placeholder={t(locale, "mounts.rootPathPlaceholder")}
value={form.rootPath}
onChange={(e) => setForm({ ...form, rootPath: e.target.value })}
/>
<div className="flex gap-2">
<Button type="submit" disabled={create.isPending}>
{t(locale, "mounts.save")}
</Button>
<Button
type="button"
variant="outline"
onClick={() =>
test.mutate({
baseUrl: form.baseUrl,
username: form.username || undefined,
password: form.password || undefined,
rootPath: form.rootPath || "/",
})
}
>
{t(locale, "mounts.test")}
</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 })}
>
{t(locale, "mounts.delete")}
</Button>
</div>
</li>
))}
{list.data?.mounts.length === 0 && (
<li className="p-4 text-sm text-muted-foreground">
{t(locale, "mounts.empty")}
</li>
)}
</ul>
</div>
);
}
const client = createTRPCReactClient();
export function MountsBody(props: { locale: Locale }) {
const [qc] = useState(() => new QueryClient());
return (
<QueryClientProvider client={qc}>
<trpc.Provider client={client} queryClient={qc}>
<MountsInner locale={props.locale} />
</trpc.Provider>
</QueryClientProvider>
);
}

View File

@ -0,0 +1,336 @@
import type { Locale } from "@app/i18n";
import { t } from "@app/i18n";
import { Button } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import Artplayer from "artplayer";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { parseDanmakuXml, startDanmakuLoop } from "~/lib/danmaku-render";
import { createTRPCReactClient, trpc } from "~/lib/trpc";
const DANMAKU_SETTINGS_KEY = "media-danmaku-settings";
type DanmakuPrefs = { enabled: boolean; opacity: number; density: number };
type ProgressInput = {
mediaItemId: string;
positionMs: number;
durationMs: number;
};
function readDanmakuPrefs(): DanmakuPrefs {
const fallback: DanmakuPrefs = { enabled: true, opacity: 0.8, density: 1 };
try {
const raw = localStorage.getItem(DANMAKU_SETTINGS_KEY);
if (!raw) return fallback;
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) return fallback;
const rec = parsed as Record<string, unknown>;
const enabled = typeof rec["enabled"] === "boolean" ? rec["enabled"] : fallback.enabled;
const opacityRaw = typeof rec["opacity"] === "number" ? rec["opacity"] : fallback.opacity;
const densityRaw = typeof rec["density"] === "number" ? rec["density"] : fallback.density;
return {
enabled,
opacity: Math.min(1, Math.max(0, opacityRaw)),
density: Math.min(1, Math.max(0.1, densityRaw)),
};
} catch {
return fallback;
}
}
/** 页面卸载时 fetch 可能被取消,用 keepalive 走与 httpBatchLink 相同的批格式兜底 */
function sendProgressBeacon(input: ProgressInput) {
const url = "/api/trpc/media.playbackReport?batch=1";
const body = JSON.stringify({ 0: input });
try {
if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
const ok = navigator.sendBeacon(url, new Blob([body], { type: "application/json" }));
if (ok) return;
}
} catch {
/* fall through */
}
try {
void fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body,
keepalive: true,
credentials: "same-origin",
});
} catch {
/* noop */
}
}
function WatchInner(props: { locale: Locale; mediaItemId: string }) {
const { locale, mediaItemId } = props;
const wrapperRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<HTMLDivElement>(null);
const artRef = useRef<Artplayer | null>(null);
const progressStartRef = useRef(0);
const [localXml, setLocalXml] = useState("");
const [danmakuOn, setDanmakuOn] = useState(true);
const [danmakuOpacity, setDanmakuOpacity] = useState(0.8);
const [danmakuDensity, setDanmakuDensity] = useState(1);
const detail = trpc.media.libraryGet.useQuery({ id: mediaItemId });
const progress = trpc.media.playbackGet.useQuery({ mediaItemId });
const openDanmaku = trpc.media.danmakuFetch.useQuery({ mediaItemId }, { enabled: danmakuOn });
const report = trpc.media.playbackReport.useMutation();
const importMeta = trpc.media.danmakuImportMeta.useMutation();
const reportRef = useRef(report);
useEffect(() => {
reportRef.current = report;
});
useEffect(() => {
const prefs = readDanmakuPrefs();
setDanmakuOn(prefs.enabled);
setDanmakuOpacity(prefs.opacity);
setDanmakuDensity(prefs.density);
}, []);
useEffect(() => {
if (progress.data) {
progressStartRef.current = progress.data.positionMs;
}
}, [progress.data]);
const detailId = detail.data?.id;
useEffect(() => {
const mountEl = playerRef.current;
if (!mountEl || !detailId) return;
const art = new Artplayer({
container: mountEl,
url: `/api/stream?id=${encodeURIComponent(mediaItemId)}`,
volume: 0.7,
autoplay: false,
pip: true,
setting: true,
playbackRate: true,
aspectRatio: true,
fullscreen: true,
theme: "#3b82f6",
});
artRef.current = art;
const seekToStart = () => {
const target = progressStartRef.current / 1000;
if (target > 0 && art.duration > target) {
art.currentTime = target;
}
};
art.on("video:loadedmetadata", seekToStart);
const snapshot = (): ProgressInput | null => {
if (!art.duration) return null;
return {
mediaItemId,
positionMs: Math.floor((art.currentTime || 0) * 1000),
durationMs: Math.floor((art.duration || 0) * 1000),
};
};
const reportNow = () => {
const input = snapshot();
if (input) reportRef.current.mutate(input);
};
let unloaded = false;
const flushUnload = () => {
if (unloaded) return;
const input = snapshot();
if (!input) return;
unloaded = true;
reportRef.current.mutate(input);
sendProgressBeacon(input);
};
let timer: ReturnType<typeof setInterval> | null = null;
art.on("play", () => {
if (timer) clearInterval(timer);
timer = setInterval(reportNow, 5000);
});
art.on("pause", () => {
if (timer) {
clearInterval(timer);
timer = null;
}
reportNow();
});
art.on("video:ended", () => {
if (timer) {
clearInterval(timer);
timer = null;
}
reportNow();
});
const onVisibility = () => {
if (document.visibilityState === "hidden") flushUnload();
};
window.addEventListener("pagehide", flushUnload);
document.addEventListener("visibilitychange", onVisibility);
return () => {
window.removeEventListener("pagehide", flushUnload);
document.removeEventListener("visibilitychange", onVisibility);
if (timer) clearInterval(timer);
flushUnload();
art.destroy(false);
artRef.current = null;
};
}, [detailId, mediaItemId]);
// 进度晚于播放器就绪时补跳一次(未开播才跳,避免打断播放)
useEffect(() => {
const art = artRef.current;
if (!art || !progress.data) return;
const target = progress.data.positionMs / 1000;
if (target > 0 && art.duration > target && !art.playing) {
art.currentTime = target;
}
}, [progress.data]);
const xmlSource = danmakuOn ? localXml || openDanmaku.data?.xml || "" : "";
const items = useMemo(() => {
const all = xmlSource ? parseDanmakuXml(xmlSource) : [];
// density < 1 时按比例抽稀,避免同屏弹幕过密
if (danmakuDensity >= 1) return all;
const keepEvery = Math.max(1, Math.round(1 / danmakuDensity));
return all.filter((_, i) => i % keepEvery === 0);
}, [xmlSource, danmakuDensity]);
const danmakuCount = items.length;
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper || items.length === 0) return;
const stop = startDanmakuLoop(
wrapper,
() => items,
() => artRef.current?.currentTime ?? 0,
{ opacity: danmakuOpacity },
);
return stop;
}, [items, danmakuOpacity]);
const onImportXml = useCallback(
(file: File) => {
const reader = new FileReader();
reader.onload = () => {
const text = String(reader.result ?? "");
setLocalXml(text);
importMeta.mutate({ mediaItemId, byteSize: file.size });
};
reader.readAsText(file);
},
[importMeta, mediaItemId],
);
const goLibrary = useCallback(() => {
window.location.href = `/${locale}/library`;
}, [locale]);
if (detail.isError) {
return (
<div className="space-y-3">
<h1 className="text-xl font-semibold">{t(locale, "watch.loadError")}</h1>
<p className="text-sm text-muted-foreground">{t(locale, "watch.loadErrorHint")}</p>
<div className="flex gap-2">
<Button size="sm" onClick={() => detail.refetch()}>
{t(locale, "error.retry")}
</Button>
<Button size="sm" variant="ghost" onClick={goLibrary}>
{t(locale, "watch.backToLibrary")}
</Button>
</div>
</div>
);
}
if (!detail.data) {
if (detail.isPending) {
return (
<div className="space-y-3">
<h1 className="text-xl font-semibold">{t(locale, "watch.loading")}</h1>
<div className="aspect-video w-full animate-pulse rounded-lg bg-muted" />
</div>
);
}
return (
<div className="space-y-3">
<h1 className="text-xl font-semibold">{t(locale, "watch.notFound")}</h1>
<p className="text-sm text-muted-foreground">{t(locale, "watch.notFoundHint")}</p>
<Button size="sm" variant="ghost" onClick={goLibrary}>
{t(locale, "watch.backToLibrary")}
</Button>
</div>
);
}
const sourceLabel = !danmakuOn
? t(locale, "watch.danmakuOff")
: localXml
? t(locale, "watch.danmakuCount", { count: danmakuCount })
: openDanmaku.data?.source === "none"
? t(locale, "watch.noDanmakuImport")
: openDanmaku.data?.source === "cache"
? t(locale, "watch.danmakuCache")
: openDanmaku.data?.source === "open-network"
? t(locale, "watch.danmakuOpenNetwork")
: t(locale, "watch.danmakuLoading");
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 ? t(locale, "watch.danmakuOn") : t(locale, "watch.danmakuOff")}
</Button>
<label className="inline-flex h-9 cursor-pointer items-center rounded-md border border-border px-3 text-sm">
{t(locale, "watch.importXml")}
<input
type="file"
accept=".xml,text/xml"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) onImportXml(f);
e.currentTarget.value = "";
}}
/>
</label>
<Button size="sm" variant="ghost" onClick={goLibrary}>
{t(locale, "watch.backToLibrary")}
</Button>
</div>
</div>
<div
ref={wrapperRef}
className="relative aspect-video w-full overflow-hidden rounded-lg bg-black"
>
<div ref={playerRef} className="absolute inset-0" />
</div>
<div className="flex justify-between text-sm text-muted-foreground">
<span>{sourceLabel}</span>
<span>{t(locale, "watch.progressHint")}</span>
</div>
</div>
);
}
const client = createTRPCReactClient();
export function WatchBody(props: { locale: Locale; mediaItemId: string }) {
const [qc] = useState(() => new QueryClient());
return (
<QueryClientProvider client={qc}>
<trpc.Provider client={client} queryClient={qc}>
<WatchInner locale={props.locale} mediaItemId={props.mediaItemId} />
</trpc.Provider>
</QueryClientProvider>
);
}

View File

@ -20,6 +20,8 @@ export interface Props {
switchHref?: string;
switchLabel?: string;
seo?: PageSeoInput;
/** 媒体页只保留 AppShell 侧栏时隐藏模板 side-rail(含左让位) */
hideSideRail?: boolean;
}
const {
@ -30,6 +32,7 @@ const {
switchHref,
switchLabel,
seo,
hideSideRail = false,
} = Astro.props;
const brandName = brand ?? t(locale, "meta.brand");
@ -125,38 +128,40 @@ const brandInitial = locale === "zh-CN" ? "模" : "A";
<div aria-hidden="true" class="bg-glow pointer-events-none fixed inset-0 z-0"></div>
<div aria-hidden="true" class="bg-vignette pointer-events-none fixed inset-0 z-0"></div>
<aside class="side-rail" aria-label={t(locale, "nav.primary")}>
<a href={brandHref} class="side-rail__brand" aria-label={brandName}>
<span
class="brand-signal flex size-8 items-center justify-center text-[12px] font-bold"
style="background: var(--edge); color: #101110;"
>
{brandInitial}
</span>
<span class="side-rail__brand-text">{brandName}</span>
</a>
<div class="side-rail__rule" aria-hidden="true"></div>
<nav class="side-rail__nav">
<a href={brandHref} class="side-rail__link is-home">
<span class="u-mono">00</span>
<span>{t(locale, "nav.home")}</span>
</a>
{
nav.map((item) => (
<a href={item.href} class="side-rail__link">
<span class="u-mono">{item.n}</span>
<span>{item.label}</span>
{
!hideSideRail && (
<aside class="side-rail" aria-label={t(locale, "nav.primary")}>
<a href={brandHref} class="side-rail__brand" aria-label={brandName}>
<span
class="brand-signal flex size-8 items-center justify-center text-[12px] font-bold"
style="background: var(--edge); color: #101110;"
>
{brandInitial}
</span>
<span class="side-rail__brand-text">{brandName}</span>
</a>
<div class="side-rail__rule" aria-hidden="true"></div>
<nav class="side-rail__nav">
<a href={brandHref} class="side-rail__link is-home">
<span class="u-mono">00</span>
<span>{t(locale, "nav.home")}</span>
</a>
))
}
</nav>
<div class="side-rail__rule" aria-hidden="true"></div>
<div class="side-rail__foot">
<ThemeSwitch client:visible />
</div>
</aside>
{nav.map((item) => (
<a href={item.href} class="side-rail__link">
<span class="u-mono">{item.n}</span>
<span>{item.label}</span>
</a>
))}
</nav>
<div class="side-rail__rule" aria-hidden="true"></div>
<div class="side-rail__foot">
<ThemeSwitch client:visible />
</div>
</aside>
)
}
<div class="site-shell">
<div class:list={["site-shell", { "site-shell--no-rail": hideSideRail }]}>
<header class="site-header">
<div class="site-header__inner">
<a href={brandHref} class="site-header__brand md:hidden" aria-label={brandName}>
@ -192,7 +197,7 @@ const brandInitial = locale === "zh-CN" ? "模" : "A";
</a>
)
}
<span class="md:hidden">
<span class={hideSideRail ? "" : "md:hidden"}>
<ThemeSwitch client:visible />
</span>
<UserMenu client:visible locale={locale} />

View File

@ -0,0 +1,95 @@
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 type DanmakuLoopOptions = {
opacity?: number | undefined;
/** 每秒横向位移 px */
speed?: number | undefined;
};
export function startDanmakuLoop(
container: HTMLElement,
getItems: () => DanmakuItem[],
getCurrentTime: () => number,
options?: DanmakuLoopOptions,
): () => void {
const layer = document.createElement("div");
layer.className = "absolute inset-0 overflow-hidden pointer-events-none";
layer.style.position = "absolute";
layer.style.zIndex = "5";
const opacity = options?.opacity;
if (typeof opacity === "number") {
layer.style.opacity = String(Math.min(1, Math.max(0, opacity)));
}
container.style.position = container.style.position || "relative";
container.appendChild(layer);
const active: { el: HTMLDivElement; start: number }[] = [];
const speed = options?.speed ?? 160;
let raf = 0;
let cursor = 0;
let lastTime = 0;
const clearActive = () => {
for (const a of active) a.el.remove();
active.length = 0;
};
const tick = () => {
const now = getCurrentTime();
const items = getItems();
// 进度回跳时重置游标,避免一次性涌出全部历史弹幕
if (now + 0.5 < lastTime) {
cursor = 0;
clearActive();
}
lastTime = now;
while (cursor < items.length) {
const it = items[cursor];
if (!it || it.time > now) break;
cursor += 1;
// 快进/加载导致的过期弹幕直接丢弃,防止一帧内刷屏
if (it.time < now - 2) continue;
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 maxY = Math.max(0, container.clientHeight - 40);
el.style.top = `${Math.random() * maxY}px`;
layer.appendChild(el);
active.push({ el, start: performance.now() });
}
const t = performance.now();
for (let i = active.length - 1; i >= 0; i -= 1) {
const a = active[i];
if (!a) continue;
const elapsed = (t - a.start) / 1000;
const x = container.clientWidth - elapsed * speed;
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);
clearActive();
layer.remove();
};
}

View File

@ -6,7 +6,15 @@ import { authService } from "@app/trpc";
const SKIP_PREFIX = ["/api", "/_astro", "/favicon", "/robots", "/sitemap", "/llms"];
/** 需登录的基础路径(无 locale 前缀)。按需增删。 */
const PROTECTED_BASES = ["/dashboard", "/settings"] as const;
const PROTECTED_BASES = [
"/dashboard",
"/settings",
"/library",
"/browse",
"/watch",
"/mounts",
"/danmaku",
] as const;
const LOCALE_SEGMENTS = new Set(["zh-CN", "en"]);

View File

@ -0,0 +1,20 @@
---
import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n";
import Browse from "~/views/browse.astro";
const raw = Astro.params["locale"];
const normalized = raw === "zh-cn" ? "zh-CN" : raw;
if (!normalized || !isLocale(normalized)) {
const preferred = resolvePreferredLocale(
Astro.cookies.get(COOKIE_LANG)?.value,
Astro.request.headers.get("accept-language"),
);
return Astro.redirect(localePath(preferred, "/browse"));
}
const locale: Locale = normalized;
---
<Browse locale={locale} />

View File

@ -0,0 +1,19 @@
---
import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n";
import Danmaku from "~/views/danmaku.astro";
const raw = Astro.params["locale"];
const normalized = raw === "zh-cn" ? "zh-CN" : raw;
if (!normalized || !isLocale(normalized)) {
const preferred = resolvePreferredLocale(
Astro.cookies.get(COOKIE_LANG)?.value,
Astro.request.headers.get("accept-language"),
);
return Astro.redirect(localePath(preferred, "/danmaku"));
}
const locale: Locale = normalized;
---
<Danmaku locale={locale} />

View File

@ -0,0 +1,19 @@
---
import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n";
import Library from "~/views/library.astro";
const raw = Astro.params["locale"];
const normalized = raw === "zh-cn" ? "zh-CN" : raw;
if (!normalized || !isLocale(normalized)) {
const preferred = resolvePreferredLocale(
Astro.cookies.get(COOKIE_LANG)?.value,
Astro.request.headers.get("accept-language"),
);
return Astro.redirect(localePath(preferred, "/library"));
}
const locale: Locale = normalized;
---
<Library locale={locale} />

View File

@ -0,0 +1,19 @@
---
import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n";
import Mounts from "~/views/mounts.astro";
const raw = Astro.params["locale"];
const normalized = raw === "zh-cn" ? "zh-CN" : raw;
if (!normalized || !isLocale(normalized)) {
const preferred = resolvePreferredLocale(
Astro.cookies.get(COOKIE_LANG)?.value,
Astro.request.headers.get("accept-language"),
);
return Astro.redirect(localePath(preferred, "/mounts"));
}
const locale: Locale = normalized;
---
<Mounts locale={locale} />

View File

@ -0,0 +1,29 @@
---
import {
COOKIE_LANG,
isLocale,
localePath,
resolvePreferredLocale,
type Locale,
} from "@app/i18n";
import Watch from "~/views/watch.astro";
const raw = Astro.params["locale"];
const normalized = raw === "zh-cn" ? "zh-CN" : raw;
if (!normalized || !isLocale(normalized)) {
const preferred = resolvePreferredLocale(
Astro.cookies.get(COOKIE_LANG)?.value,
Astro.request.headers.get("accept-language"),
);
return Astro.redirect(localePath(preferred, "/watch"));
}
const locale: Locale = normalized;
const mediaItemId = Astro.url.searchParams.get("id") ?? "";
if (!mediaItemId) {
return Astro.redirect(localePath(locale, "/library"));
}
---
<Watch locale={locale} mediaItemId={mediaItemId} />

View File

@ -0,0 +1,31 @@
/** 认证后的 Range 流代理:鉴权在本路由,webdav 细节封装在 @app/trpc 的 openRemoteFileStream。 */
import type { APIRoute } from "astro";
import { COOKIE_SESSION } from "@app/types";
import { authService, 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 });
}
};

View File

@ -471,12 +471,18 @@ html.has-reveal astro-island .u-reveal {
padding-left: 8.5rem;
padding-right: 3.5rem;
}
.site-shell--no-rail {
padding-left: 3.5rem;
}
}
@media (min-width: 1280px) {
.site-shell {
padding-left: 10.5rem;
padding-right: 5rem;
}
.site-shell--no-rail {
padding-left: 5rem;
}
}
.site-shell--page {
height: 100dvh;

View File

@ -0,0 +1,41 @@
---
import { alternatePath, type Locale, t } from "@app/i18n";
import { Toaster } from "@app/ui";
import { AppShell } from "~/components/AppShell";
import { BrowseBody } from "~/components/pages/BrowseBody";
import AppLayout from "~/layouts/AppLayout.astro";
export interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
const labels: Record<string, string> = {
"meta.brand": t(locale, "meta.brand"),
"nav.library": t(locale, "nav.library"),
"nav.browse": t(locale, "nav.browse"),
"nav.mounts": t(locale, "nav.mounts"),
"nav.danmaku": t(locale, "nav.danmaku"),
};
---
<AppLayout
title={t(locale, "browse.title")}
locale={locale}
nav={[]}
hideSideRail
switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)}
seo={{
title: `${t(locale, "browse.title")} · ${t(locale, "meta.brand")}`,
path: Astro.url.pathname,
robots: ["noindex", "nofollow"],
}}
>
<AppShell locale={locale} active="browse" labels={labels}>
<BrowseBody locale={locale} client:load />
</AppShell>
<Toaster client:load />
</AppLayout>

View File

@ -0,0 +1,40 @@
---
import { alternatePath, type Locale, t } from "@app/i18n";
import { Toaster } from "@app/ui";
import { AppShell } from "~/components/AppShell";
import { DanmakuSettingsBody } from "~/components/pages/DanmakuSettingsBody";
import AppLayout from "~/layouts/AppLayout.astro";
export interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
const labels: Record<string, string> = {
"meta.brand": t(locale, "meta.brand"),
"nav.library": t(locale, "nav.library"),
"nav.browse": t(locale, "nav.browse"),
"nav.mounts": t(locale, "nav.mounts"),
"nav.danmaku": t(locale, "nav.danmaku"),
};
---
<AppLayout
title={t(locale, "danmaku.title")}
locale={locale}
nav={[]}
hideSideRail
switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)}
seo={{
title: `${t(locale, "danmaku.title")} · ${t(locale, "meta.brand")}`,
path: Astro.url.pathname,
robots: ["noindex", "nofollow"],
}}
>
<AppShell locale={locale} active="danmaku" labels={labels}>
<DanmakuSettingsBody locale={locale} client:load />
</AppShell>
<Toaster client:load />
</AppLayout>

View File

@ -0,0 +1,40 @@
---
import { alternatePath, type Locale, t } from "@app/i18n";
import { Toaster } from "@app/ui";
import { AppShell } from "~/components/AppShell";
import { LibraryBody } from "~/components/pages/LibraryBody";
import AppLayout from "~/layouts/AppLayout.astro";
export interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
const labels: Record<string, string> = {
"meta.brand": t(locale, "meta.brand"),
"nav.library": t(locale, "nav.library"),
"nav.browse": t(locale, "nav.browse"),
"nav.mounts": t(locale, "nav.mounts"),
"nav.danmaku": t(locale, "nav.danmaku"),
};
---
<AppLayout
title={t(locale, "library.title")}
locale={locale}
nav={[]}
hideSideRail
switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)}
seo={{
title: `${t(locale, "library.title")} · ${t(locale, "meta.brand")}`,
path: Astro.url.pathname,
robots: ["noindex", "nofollow"],
}}
>
<AppShell locale={locale} active="library" labels={labels}>
<LibraryBody locale={locale} client:load />
</AppShell>
<Toaster client:load />
</AppLayout>

View File

@ -0,0 +1,40 @@
---
import { alternatePath, type Locale, t } from "@app/i18n";
import { Toaster } from "@app/ui";
import { AppShell } from "~/components/AppShell";
import { MountsBody } from "~/components/pages/MountsBody";
import AppLayout from "~/layouts/AppLayout.astro";
export interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
const labels: Record<string, string> = {
"meta.brand": t(locale, "meta.brand"),
"nav.library": t(locale, "nav.library"),
"nav.browse": t(locale, "nav.browse"),
"nav.mounts": t(locale, "nav.mounts"),
"nav.danmaku": t(locale, "nav.danmaku"),
};
---
<AppLayout
title={t(locale, "mounts.title")}
locale={locale}
nav={[]}
hideSideRail
switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)}
seo={{
title: `${t(locale, "mounts.title")} · ${t(locale, "meta.brand")}`,
path: Astro.url.pathname,
robots: ["noindex", "nofollow"],
}}
>
<AppShell locale={locale} active="mounts" labels={labels}>
<MountsBody locale={locale} client:load />
</AppShell>
<Toaster client:load />
</AppLayout>

View File

@ -0,0 +1,41 @@
---
import { alternatePath, type Locale, t } from "@app/i18n";
import { Toaster } from "@app/ui";
import { AppShell } from "~/components/AppShell";
import { WatchBody } from "~/components/pages/WatchBody";
import AppLayout from "~/layouts/AppLayout.astro";
export interface Props {
locale: Locale;
mediaItemId: string;
}
const { locale, mediaItemId } = Astro.props;
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
const labels: Record<string, string> = {
"meta.brand": t(locale, "meta.brand"),
"nav.library": t(locale, "nav.library"),
"nav.browse": t(locale, "nav.browse"),
"nav.mounts": t(locale, "nav.mounts"),
"nav.danmaku": t(locale, "nav.danmaku"),
};
---
<AppLayout
title={t(locale, "watch.title")}
locale={locale}
nav={[]}
hideSideRail
switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)}
seo={{
title: `${t(locale, "watch.title")} · ${t(locale, "meta.brand")}`,
path: Astro.url.pathname,
robots: ["noindex", "nofollow"],
}}
>
<AppShell locale={locale} active="watch" labels={labels}>
<WatchBody locale={locale} mediaItemId={mediaItemId} client:load />
</AppShell>
<Toaster client:load />
</AppLayout>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,219 @@
# Web 版弹弹play(媒体中心)— 设计规格
- **日期**:2026-09-25
- **状态**:已获设计批准,待实现计划(writing-plans)
- **仓库**:`app-template`(按**产品仓**维护,业务进入现有分层)
## 1. 背景与调研结论
弹弹play 官方定位是**本地视频 + 在线弹幕**的全功能播放器(非音乐播放器),能力包括智能识别、媒体库、AI 字幕、远程访问(浏览器可连 PC 内嵌 API)、媒体库可挂 SMB/WebDAV/Emby 等。
本项目要做的是:**独立 Web 应用**(方案 A),提供类似的「挂载源 → 浏览 → 播放 + 弹幕 → 媒体库/进度」体验,**不依赖**本机运行的 PC 弹弹play。
| 能力 | 决策 |
|------|------|
| 范围 | A — 独立完整 Web 应用 |
| 播放器 | ArtPlayer |
| 弹幕 | 对接弹弹play **开放弹幕网络**(服务端中继)+ 本地 XML 导入兜底 |
| 存储源 | **仅通用 WebDAV**(OpenList 使用其 WebDAV 端点;不做 OpenList 原生 API) |
| 后端 | 挂载代理 + 弹幕中继 + 媒体库/进度 **全做** |
| 刮削 | **Bangumi**:扫描自动匹配 + 手动搜索改绑(C) |
| UI | 全页面:播放 / 文件浏览 / 媒体库 / 挂载管理 / 弹幕设置 |
| 账号 | **多用户**:注册登录;每人独立库与进度 |
| 架构方案 | **A — 紧凑垂直切片 `media`**(复制 `user` 切片模式) |
| 视觉 | **shadcn/ui Dashboard** 风格(侧栏 + 卡片 + 中性灰阶),不以模板深空工业 HUD 为产品主视觉 |
| 落点 | **B — 产品化本仓**:业务进入 `app/web`、`packages/*` |
### 调研来源(Browser Use 实读)
- https://www.dandanplay.com/ — 产品能力与远程访问/WebDAV 叙述
- https://doc.dandanplay.com/open/library-api.html — PC 远程访问 REST(本项目**不**对接;仅作产品对照)
- https://doc.dandanplay.com/function/media-library.html — 媒体库信息架构参考
- https://github.com/OpenListTeam/OpenList — AList fork;本项目只用 **WebDAV** 接入方式
> 说明:会话内 IAB 不可用,调研经 `cua_repl` + `managed-chromium` 完成 SERP 与正文阅读。
## 2. 系统架构(方案 A)
```
浏览器 @app/web(React islands,shadcn dashboard)
├─ 登录 / 媒体库 / 文件浏览 / 播放(ArtPlayer+弹幕) / 挂载 / 弹幕设置
└─ 仅经 tRPC 与 /api/stream(Range)访问
│
▼
服务层 trpc + service + dao(@app/trpc · dao)
├─ mount.* WebDAV 列目录、凭据加密、测试连接
├─ stream Range 拉流代理
├─ danmaku.* 开放弹幕网络中继 + 缓存
├─ scrape.* Bangumi 搜索/绑定;扫描内自动刮削
├─ library.* 扫描入库、列表、海报
└─ playback.* 进度节流写入
│
┌─────┴──────┐
▼ ▼
Drizzle/Turso 外部:WebDAV · 弹弹play 开放弹幕网络 · Bangumi API
```
**分层约束(沿用仓库 AGENTS)**
- Schema 源:`@app/models`;查询:`@app/dao`;连接:`@app/db`
- 业务逻辑:`@app/trpc` service(调 dao);入参 Zod:`@app/types`
- UI:`@app/ui` + design tokens;页面在 `@app/web`
- **产品化本仓**:新增业务实体照 `user` 垂直切片扩展(本产品期间业务代码进入现有分层,视为对「模板不掺业务」的产品化豁免)
### 播放主路径
1. 登录 → 2. 配置 WebDAV 挂载 → 3. 浏览目录或扫描入库
4. 打开视频 → stream 代理 → ArtPlayer
5. 识别/拉弹幕(中继 + `danmaku_cache`)→ 弹幕层
6. 节流上报 `playback_progress`
### 首版不做
OpenList 原生 API · 本机弹弹play 远程 API 对接 · BT/RSS · 多刮削源 · NFO 优先 · 公网多租户硬化(SSO/套餐限流)
## 3. 数据模型
### users(已有 auth)
沿用模板用户表与会话,不重复造账号。
### mounts
| 字段 | 说明 |
|------|------|
| id | PK |
| userId | → users,级联隔离 |
| name | 展示名 |
| type | 固定 `webdav`(预留枚举) |
| baseUrl | WebDAV 根 URL(含 OpenList WebDAV 地址) |
| username | 可选 |
| secretEnc | **服务端加密**密文;API 永不回传明文 |
| rootPath | 根路径前缀 |
| enabled | 启用 |
### media_items
| 字段 | 说明 |
|------|------|
| id | PK |
| userId | 所属用户 |
| mountId | 可空(扫描所得) |
| path | 挂载内路径;`userId+path` 唯一 |
| rawName | 原始文件名 |
| title | 展示标题(可被刮削覆盖) |
| size / mime | 文件元数据 |
| bangumiId? | 刮削绑定 |
| epNumber? | 集数启发式 |
| scrapeStatus | `pending` \| `ok` \| `failed` \| `unmatched` |
| scrapedAt? | 最近刮削时间 |
| posterUrl? | 海报(Bangumi CDN 或占位) |
| matchedHash? | 弹幕匹配指纹(预留) |
| scannedAt | 入库时间 |
### playback_progress
`id · userId · mediaItemId · positionMs · durationMs · updatedAt`
唯一:`(userId, mediaItemId)`。
### danmaku_cache(全局,不绑 user)
`id · matchKey · payload/xml · source · expiresAt`
### bangumi_cache(可选,限流)
`bangumiId · payload JSON · expiresAt`
### Zod(@app/types)
- `mountCreateSchema` / `mountUpdateSchema`
- `scanMountSchema` · `playbackReportSchema`
- `danmakuQuerySchema` · `danmakuImportMetaSchema`
- `scrapeSearchSchema` · `scrapeBindSchema`
## 4. 页面与交互
### 路由(shadcn dashboard 侧栏)
| 路径 | 页面 |
|------|------|
| `/login` `/register` | 认证(登录前仅此可见) |
| `/library` | 媒体库(默认登录后):海报卡、筛选全部/未匹配、继续播放、扫描入口 |
| `/browse` | 文件浏览:选挂载 → 面包屑目录 → 行内播放/加入库 |
| `/watch` | 播放:ArtPlayer + 弹幕层 + 同目录剧集(可选)+ 弹幕开关/导入 XML |
| `/mounts` | 挂载管理:CRUD、测试连接、按挂载扫描 |
| `/danmaku` | 弹幕设置:默认开关/透明度/密度;导入说明 |
### 主流程
1. 注册/登录
2. 挂载管理添加 WebDAV → 测试 → 保存
3. 文件浏览点选播放,或「扫描」→ 刮削管线 → 媒体库
4. `/watch`:stream → 弹幕中继 → 进度保存
5. 媒体库「继续播放」带 progress 打开 `/watch`
6. 未匹配项手动 Bangumi 搜索绑定;详情可重新刮削
### 刮削管线(扫描)
1. 列目录,视频入 `media_items`
2. 文件名启发式 → Bangumi 搜索
3. 命中 → 写元数据/海报/`bangumiId`
4. 低置信 → `unmatched`,UI 手动搜绑
5. 重复扫描按指纹/缓存,避免打爆 Bangumi;**刮削失败不阻断入库**
### 视觉
- shadcn 风格组件语义:侧栏 nav、卡片、primary 按钮、Sheet 挂载表单、Dialog 确认、Toast
- 可基于 `@app/ui` 对齐 shadcn API,产品主题为中性暗色 dashboard(非工业信号黄主视觉)
- 动效克制,尊重 `prefers-reduced-motion`
## 5. 错误处理
| 场景 | 策略 |
|------|------|
| WebDAV 401/超时 | 明确文案;浏览 skeleton + 重试;引导改凭据 |
| stream 失败 | 播放器错误 + Toast;不暴露栈 |
| 弹幕网络失败 | 降级无弹幕,可播;可仅本地 XML |
| Bangumi 失败 | `scrapeStatus=failed`,可重试;不回滚文件 |
| 进度失败 | 静默重试,不打断播放 |
| 权限 | 所有 library/mount/progress 查询强制 `userId` |
## 6. 测试与验证
- types:Zod 单测
- dao/service:进度 upsert、刮削绑定、路径规范化(mock HTTP)
- tRPC:跨用户越权
- web:主路径冒烟(登录→列表→播放页);无 CI 浏览器则手动清单
- 命令:`yarn typecheck` · `yarn lint` · `yarn build:web`
- 不做重型视觉回归
## 7. 实现范围(一个计划可覆盖)
**In scope**
- media 垂直切片全栈(mount/stream/danmaku/scrape/library/playback)
- 六个 UI 面(含登录沿用)
- ArtPlayer 集成与弹幕层(开放网络 + XML 导入)
- Bangumi 自动/手动刮削
- shadcn dashboard 信息架构与主题
**Out of scope(本规格)**
见 §2「首版不做」及 §1 调研中未选路径。
## 8. 风险与开放问题(实现前可再核)
1. **弹弹play 开放弹幕网络** 端点、鉴权与条款:实现时以官方文档/实测为准,service 单点适配
2. **Bangumi API** 限流与字段:实现时核对官方 API 文档
3. **WebDAV 流**:Range、HTTPS 混合内容、大文件内存——stream 用流式管道,禁止整文件缓冲
4. 艺术墙纸/版权:用户自备片源与挂载凭据
## 9. 批准记录
| 节 | 结果 |
|----|------|
| 架构 + shadcn dashboard | OK |
| 数据模型 | OK |
| 页面与交互 + Bangumi 刮削 C | OK |
| 错误/测试/边界 | OK |
| 方案选型 | A |

View File

@ -0,0 +1,45 @@
import { and, eq, gt, isNull, or } 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;
},
};

View File

@ -5,3 +5,9 @@ export { sessionDao } from "./sessions.js";
export type { SessionRow } from "./sessions.js";
export { passwordResetDao } from "./password-resets.js";
export type { PasswordResetRow } from "./password-resets.js";
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";

View File

@ -0,0 +1,124 @@
import { and, desc, eq, like, or, sql } from "drizzle-orm";
import { mediaItems, type MediaItem } 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}%`;
const qCond = or(like(mediaItems.title, likeQ), like(mediaItems.rawName, likeQ));
if (qCond) conds.push(qCond);
}
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,
mountId: string,
path: string,
): Promise<MediaItemRow | null> {
const [row] = await db
.select()
.from(mediaItems)
.where(
and(
eq(mediaItems.userId, userId),
eq(mediaItems.mountId, mountId),
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.mountId, 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;
},
};

View File

@ -0,0 +1,44 @@
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)));
},
};

View File

@ -0,0 +1,43 @@
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;
},
};

View File

@ -9,6 +9,11 @@ export const en: Record<MessageKey, string> = {
"nav.dashboard": "Dashboard",
"nav.primary": "Primary nav",
"nav.brandHome": "Home",
"nav.library": "Library",
"nav.browse": "Browse",
"nav.watch": "Watch",
"nav.mounts": "Mounts",
"nav.danmaku": "Danmaku settings",
"home.title": "App Template",
"home.subtitle":
@ -31,6 +36,80 @@ export const en: Record<MessageKey, string> = {
"dashboard.desc":
"Full vertical example: tRPC → service → dao → db (scaffold baseline — replace with your domain).",
"library.title": "Library",
"library.scan": "Scan mounts",
"library.unmatched": "Unmatched",
"library.continue": "Continue watching",
"library.searchPlaceholder": "Search title",
"library.filterAll": "All",
"library.filterOk": "Scraped",
"library.noPoster": "No poster",
"library.play": "Play",
"library.match": "Match",
"library.empty": "No media yet. Scan a mount in the file browser first.",
"library.loadMore": "Load more",
"library.matchTitle": "Manual Bangumi match",
"library.searchWork": "Search by title",
"library.bind": "Bind",
"library.cancel": "Cancel",
"browse.title": "File browser",
"browse.play": "Play",
"browse.addLibrary": "Scan into library",
"browse.selectMount": "Select mount…",
"browse.mountsEmpty": "No mounts yet",
"browse.root": "Root",
"browse.up": "Up",
"browse.empty": "Empty directory or no mount selected",
"browse.loading": "Loading…",
"browse.loadError": "Failed to read directory",
"browse.playError": "Failed to add to library",
"browse.scanDone": "Scan complete: {count} files",
"browse.scanError": "Scan failed. Check the mount configuration.",
"mounts.title": "Mounts",
"mounts.add": "Add WebDAV",
"mounts.test": "Test connection",
"mounts.openlistHint": "For OpenList, use its WebDAV URL (e.g. https://host/dav)",
"mounts.credentialHint": "Credentials are stored on the server only.",
"mounts.namePlaceholder": "Name, e.g. OpenList",
"mounts.baseUrlPlaceholder": "https://example.com/dav",
"mounts.usernamePlaceholder": "Username (optional)",
"mounts.passwordPlaceholder": "Password / token",
"mounts.rootPathPlaceholder": "Root path /",
"mounts.save": "Save",
"mounts.delete": "Delete",
"mounts.empty": "No mounts yet",
"watch.title": "Watch",
"watch.loading": "Loading…",
"watch.danmakuOn": "Danmaku on",
"watch.danmakuOff": "Danmaku off",
"watch.importXml": "Import local XML",
"watch.noDanmaku": "No online danmaku yet",
"watch.noDanmakuImport": "No online danmaku yet; import an XML",
"watch.danmakuCache": "Danmaku (cache)",
"watch.danmakuOpenNetwork": "Danmaku (open network)",
"watch.danmakuLoading": "Loading danmaku…",
"watch.danmakuCount": "{count} danmaku",
"watch.backToLibrary": "Back to library",
"watch.progressHint": "Progress saved every ~5s",
"watch.loadError": "Failed to load",
"watch.loadErrorHint": "Could not load this media. Check the link or try again later.",
"watch.notFound": "Media not found",
"watch.notFoundHint": "This media may have been removed, or you do not have access.",
"danmaku.title": "Danmaku settings",
"danmaku.opacity": "Opacity",
"danmaku.density": "Density",
"danmaku.enabled": "Enable danmaku",
"danmaku.opacityValue": "Opacity {value}%",
"danmaku.densityValue": "Density {value}x",
"danmaku.save": "Save",
"danmaku.saved": "Saved (local session)",
"danmaku.localImportHint":
"Import local XML on the watch page; this saves the default toggle and display options.",
"auth.login.title": "Sign in",
"auth.login.heading": "Sign in",
"auth.login.subtitle": "Continue with email and password",

View File

@ -11,6 +11,11 @@ export const zhCN = {
"nav.dashboard": "仪表盘",
"nav.primary": "主导航",
"nav.brandHome": "首页",
"nav.library": "媒体库",
"nav.browse": "文件浏览",
"nav.watch": "播放",
"nav.mounts": "挂载管理",
"nav.danmaku": "弹幕设置",
"home.title": "应用模板",
"home.subtitle": "Astro + tRPC + Drizzle monorepo 脚手架。登录后可进入仪表盘查看用户列表示例。",
@ -32,6 +37,79 @@ export const zhCN = {
"dashboard.desc":
"tRPC → service → dao → db 的完整垂直示例(脚手架基线,可替换为你的业务实体)。",
"library.title": "媒体库",
"library.scan": "扫描挂载",
"library.unmatched": "未匹配",
"library.continue": "继续播放",
"library.searchPlaceholder": "搜索标题",
"library.filterAll": "全部",
"library.filterOk": "已刮削",
"library.noPoster": "无封面",
"library.play": "播放",
"library.match": "匹配",
"library.empty": "暂无媒体,请先在文件浏览中扫描挂载。",
"library.loadMore": "加载更多",
"library.matchTitle": "手动匹配 Bangumi",
"library.searchWork": "搜索作品名",
"library.bind": "绑定",
"library.cancel": "取消",
"browse.title": "文件浏览",
"browse.play": "播放",
"browse.addLibrary": "扫描入库",
"browse.selectMount": "选择挂载…",
"browse.mountsEmpty": "暂无挂载,请先添加",
"browse.root": "根目录",
"browse.up": "上一级",
"browse.empty": "空目录或未选择挂载",
"browse.loading": "加载中…",
"browse.loadError": "目录读取失败",
"browse.playError": "入库失败,请重试",
"browse.scanDone": "扫描完成:{count} 个文件",
"browse.scanError": "扫描失败,请检查挂载配置",
"mounts.title": "挂载管理",
"mounts.add": "添加 WebDAV",
"mounts.test": "测试连接",
"mounts.openlistHint": "OpenList 请填写其 WebDAV 地址(如 https://host/dav)",
"mounts.credentialHint": "凭据仅保存在服务端。",
"mounts.namePlaceholder": "名称,如 OpenList",
"mounts.baseUrlPlaceholder": "https://example.com/dav",
"mounts.usernamePlaceholder": "用户名(可选)",
"mounts.passwordPlaceholder": "密码 / 令牌",
"mounts.rootPathPlaceholder": "根路径 /",
"mounts.save": "保存",
"mounts.delete": "删除",
"mounts.empty": "尚未添加挂载",
"watch.title": "播放",
"watch.loading": "加载中…",
"watch.danmakuOn": "弹幕开",
"watch.danmakuOff": "弹幕关",
"watch.importXml": "导入本地 XML",
"watch.noDanmaku": "暂无在线弹幕",
"watch.noDanmakuImport": "暂无在线弹幕,可导入 XML",
"watch.danmakuCache": "弹幕(缓存)",
"watch.danmakuOpenNetwork": "弹幕(开放网络)",
"watch.danmakuLoading": "弹幕加载中…",
"watch.danmakuCount": "弹幕 {count} 条",
"watch.backToLibrary": "返回媒体库",
"watch.progressHint": "进度约每 5 秒保存",
"watch.loadError": "加载失败",
"watch.loadErrorHint": "无法加载该媒体,请检查链接或稍后重试。",
"watch.notFound": "媒体不存在",
"watch.notFoundHint": "该媒体可能已被删除,或你没有访问权限。",
"danmaku.title": "弹幕设置",
"danmaku.opacity": "不透明度",
"danmaku.density": "密度",
"danmaku.enabled": "启用弹幕",
"danmaku.opacityValue": "不透明度 {value}%",
"danmaku.densityValue": "密度 {value}x",
"danmaku.save": "保存",
"danmaku.saved": "已保存(本地会话)",
"danmaku.localImportHint": "本地 XML 导入在播放页操作;此处保存默认开关与显示参数。",
"auth.login.title": "登录",
"auth.login.heading": "登录",
"auth.login.subtitle": "使用邮箱与密码继续",

View File

@ -0,0 +1,20 @@
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;

View File

@ -5,3 +5,11 @@ export { sessions } from "./sessions.js";
export type { Session, NewSession } from "./sessions.js";
export { passwordResets } from "./password-resets.js";
export type { PasswordReset, NewPasswordReset } from "./password-resets.js";
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";

View File

@ -0,0 +1,37 @@
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_mount_path_uq").on(t.userId, t.mountId, t.path)],
);
export type MediaItem = typeof mediaItems.$inferSelect;
export type NewMediaItem = typeof mediaItems.$inferInsert;

View File

@ -0,0 +1,32 @@
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;

View File

@ -0,0 +1,26 @@
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;

View File

@ -18,6 +18,7 @@
"scripts": {
"build": "vite build && tsc -p tsconfig.dts.json",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"lint": "biome check .",
"dev": "tsx watch src/server.ts"
},
@ -27,12 +28,14 @@
"@app/models": "workspace:*",
"@app/types": "workspace:*",
"@trpc/server": "^11.19.0",
"drizzle-orm": "^0.45.3"
"drizzle-orm": "^0.45.3",
"webdav": "^5.11.0"
},
"devDependencies": {
"@types/node": "^22.15.29",
"tsx": "^4.19.4",
"typescript": "^7.0.2",
"vite": "^8.3.0"
"vite": "^8.3.0",
"vitest": "^5.0.1"
}
}

View File

@ -2,6 +2,7 @@
// Router
export { appRouter } from "./router/index.js";
export type { AppRouter, RouterInputs, RouterOutputs, TrpcContext } from "./router/index.js";
export { mediaRouter } from "./router/media.router.js";
// Service layer — usable without tRPC (tests, scripts)
export { userService } from "./services/user.service.js";
@ -27,3 +28,22 @@ export type {
RequestResetInput,
ResetPasswordInput,
} from "./services/auth.service.js";
// Media / WebDAV
export { mountService } from "./services/mount.service.js";
export type { MountPublic } from "./services/mount.service.js";
export { libraryService } from "./services/library.service.js";
export {
scrapeService,
parseEpisodeFromFilename,
buildSearchQuery,
} from "./services/scrape.service.js";
export type { BangumiSearchHit } from "./services/scrape.service.js";
// Playback / danmaku
export { playbackService } from "./services/playback.service.js";
export { danmakuService } from "./services/danmaku.service.js";
// Stream range proxy
export { openRemoteFileStream } from "./services/stream.service.js";
export type { RemoteFileStream } from "./services/stream.service.js";

View File

@ -8,6 +8,7 @@ import { userSchemas } from "@app/types";
import { userService } from "../services/user.service.js";
import type { TrpcContext } from "../context.js";
import { authRouter } from "./auth.router.js";
import { mediaRouter } from "./media.router.js";
// ─── User router ────────────────────────────────────────────────────────
export const userRouter = t.router({
@ -34,6 +35,7 @@ export const userRouter = t.router({
export const appRouter = t.router({
user: userRouter,
auth: authRouter,
media: mediaRouter,
});
// ─── Type re-exports ────────────────────────────────────────────────────

View File

@ -0,0 +1,90 @@
import {
danmakuSchemas,
librarySchemas,
mountSchemas,
playbackSchemas,
scrapeSchemas,
} from "@app/types";
import { t } from "../context.js";
import { danmakuService } from "../services/danmaku.service.js";
import { libraryService } from "../services/library.service.js";
import { mountService } from "../services/mount.service.js";
import { playbackService } from "../services/playback.service.js";
import { protectedProcedure } from "../services/procedure.js";
import { scrapeService } from "../services/scrape.service.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)),
libraryEnsurePath: protectedProcedure
.input(librarySchemas.ensurePath)
.mutation(({ ctx, input }) =>
libraryService.ensurePath(ctx.userId, input.mountId, input.path),
),
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),
});

View File

@ -0,0 +1,41 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import { generateOpenSignature, headMd5, openCommentsToXml } from "./danmaku.service.js";
describe("danmaku open network helpers", () => {
it("headMd5 matches md5 of sample", () => {
const sample = Buffer.from("hello");
expect(headMd5(sample)).toBe(createHash("md5").update(sample).digest("hex"));
});
it("signature is base64(sha256(AppId + Timestamp + Path + AppSecret))", () => {
const sig = generateOpenSignature("app", 1735660800, "/api/v2/comment/1", "sec");
const expectSig = createHash("sha256")
.update("app" + "1735660800" + "/api/v2/comment/1" + "sec")
.digest("base64");
expect(sig).toBe(expectSig);
});
it("comments p/m maps to bilibili xml", () => {
const xml = openCommentsToXml([
{ p: "12.5,1,16777215", m: "hello" },
{ p: "3,5,16777215", m: "top & <b>" },
]);
expect(xml).toContain('<d p="12.5,1,0,0">hello</d>');
expect(xml).toContain('<d p="3,5,0,0">top &amp; &lt;b&gt;</d>');
});
it("comments with explicit fields also work", () => {
const xml = openCommentsToXml([{ time: 1.25, mode: 4, text: "x" }]);
expect(xml).toContain('<d p="1.25,4,0,0">x</d>');
});
it("skips invalid comments", () => {
expect(
openCommentsToXml([
{ p: "abc,1", m: "x" },
{ p: "1,1", m: "" },
]),
).toBe('<?xml version="1.0" encoding="UTF-8"?><i></i>');
});
});

View File

@ -0,0 +1,206 @@
import { createHash } from "node:crypto";
import { TRPCError } from "@trpc/server";
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;
/** 官方文件识别 hash:文件前 16MB 的 MD5(doc.dandanplay.com/open) */
const HASH_HEAD_BYTES = 16 * 1024 * 1024;
type OpenComment = {
p?: string;
m?: string;
text?: string;
mode?: number;
time?: number;
};
function openApiBase(): string {
return (process.env["OPEN_DANMAKU_API_BASE"] ?? "https://api.dandanplay.net").replace(
/\/+$/,
"",
);
}
function openCredentials(): { appId: string; appSecret: string } | null {
const appId = process.env["OPEN_DANMAKU_APP_ID"];
const appSecret = process.env["OPEN_DANMAKU_APP_SECRET"];
if (!appId || !appSecret) return null;
return { appId, appSecret };
}
/** 官方签名:base64(sha256(AppId + Timestamp + Path + AppSecret)) */
export function generateOpenSignature(
appId: string,
timestamp: number,
path: string,
appSecret: string,
): string {
return createHash("sha256").update(`${appId}${timestamp}${path}${appSecret}`).digest("base64");
}
/** 文件头部 MD5(样本应已截到 16MB 内) */
export function headMd5(sample: Buffer): string {
return createHash("md5").update(sample).digest("hex");
}
function escapeXml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** 开放网络 comments → Bilibili 式 XML(`<d p="time,mode,0,0">text</d>`) */
export function openCommentsToXml(comments: OpenComment[]): string {
const nodes: string[] = [];
for (const c of comments) {
let time = Number.NaN;
let mode = 1;
let text = "";
if (typeof c.p === "string") {
const [t, m] = c.p.split(",");
time = Number(t);
mode = Number(m) || 1;
text = c.m ?? "";
} else {
time = Number(c.time);
mode = Number(c.mode) || 1;
text = c.text ?? c.m ?? "";
}
if (!Number.isFinite(time) || !text) continue;
nodes.push(`<d p="${time},${mode},0,0">${escapeXml(text)}</d>`);
}
return `<?xml version="1.0" encoding="UTF-8"?><i>${nodes.join("")}</i>`;
}
function openHeaders(path: string): Record<string, string> {
const creds = openCredentials();
if (!creds) return {};
const timestamp = Math.floor(Date.now() / 1000);
return {
"Content-Type": "application/json",
"X-AppId": creds.appId,
"X-Timestamp": String(timestamp),
"X-Signature": generateOpenSignature(creds.appId, timestamp, path, creds.appSecret),
};
}
function matchKeyFor(path: string, size: number): string {
return createHash("sha256").update(`${path}:${size}`).digest("hex");
}
/** 取 WebDAV 文件前 16MB 的 MD5(开放网络文件识别用) */
async function remoteFileHash(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 = client.createReadStream(abs, {
range: { start: 0, end: HASH_HEAD_BYTES - 1 },
});
const sample = await new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on("data", (chunk: Buffer) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
});
stream.on("end", () => resolve(Buffer.concat(chunks)));
stream.on("error", reject);
});
return headMd5(sample.subarray(0, HASH_HEAD_BYTES));
} catch {
return null;
}
}
async function fetchOpenNetworkXml(
path: string,
size: number,
hash: string | null,
): Promise<string | null> {
const base = openApiBase();
const matchPath = "/api/v2/match";
try {
const matchRes = await fetch(`${base}${matchPath}`, {
method: "POST",
headers: openHeaders(matchPath),
body: JSON.stringify({
fileName: path.split("/").pop() ?? path,
fileHash: hash ?? "",
fileSize: size,
matchMode: hash ? "hashAndFileName" : "fileNameOnly",
}),
});
if (!matchRes.ok) return null;
const matched = (await matchRes.json()) as {
isMatched?: boolean;
success?: boolean;
matches?: Array<{ episodeId?: number | string }>;
};
if (matched.success === false) return null;
const episodeId = matched.matches?.[0]?.episodeId;
if (!episodeId) return null;
if (matched.isMatched === false) return null;
const commentPath = `/api/v2/comment/${episodeId}`;
const commentRes = await fetch(`${base}${commentPath}?withRelated=true`, {
headers: openHeaders(commentPath),
});
if (!commentRes.ok) return null;
const body = (await commentRes.json()) as { comments?: OpenComment[] };
const comments = body.comments ?? [];
return openCommentsToXml(comments);
} 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: "媒体不存在" };
if (!openCredentials()) {
return { ok: true, 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 remoteFileHash(userId, mediaItemId);
const xml = await fetchOpenNetworkXml(item.path, item.size, hash);
if (xml === null) {
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 由前端解析后直接喂播放器;此处仅校验媒体存在与体积。 */
async importMeta(userId: string, mediaItemId: string, byteSize: number): Promise<{ ok: true }> {
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "媒体不存在" });
if (byteSize > 20_000_000) {
throw new TRPCError({ code: "BAD_REQUEST", message: "XML 过大" });
}
return { ok: true };
},
};

View File

@ -0,0 +1,126 @@
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;
/** 同时执行的 upsert+刮削 job 上限,避免最坏 2000 并发打 Bangumi 被限流 */
const SCRAPE_CONCURRENCY = 4;
type ScanJob = () => Promise<void>;
async function walkVideos(
userId: string,
mountId: string,
root: string,
rel: string,
budget: { left: number },
out: ScanJob[],
): 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);
const entries = await listDirectory(client, abs).catch(() => null);
if (!entries) 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, "");
// 延迟执行:扫描阶段只收集 job,随后分批限流
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: ScanJob[] = [];
await walkVideos(userId, mountId, mount.rootPath || "/", "/", budget, jobs);
for (let i = 0; i < jobs.length; i += SCRAPE_CONCURRENCY) {
const batch = jobs.slice(i, i + SCRAPE_CONCURRENCY).map((job) => job());
await Promise.allSettled(batch);
}
return { scanned: MAX_SCAN_FILES - budget.left, message: "扫描完成" };
},
/** 单文件入库(Browse 点播放前调用);已入库则刷新元数据后返回。 */
async ensurePath(userId: string, mountId: string, path: string): Promise<MediaItemRow> {
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 client = createWebdav({
baseUrl: mount.baseUrl,
username: mount.username ?? undefined,
password: mount.secretEnc ? decryptSecret(mount.secretEnc) : undefined,
});
const abs = joinWebdavPath(mount.rootPath, path);
let size = 0;
try {
const stat = (await client.stat(abs)) as { size?: number };
size = Number(stat.size ?? 0);
} catch {
throw new TRPCError({ code: "NOT_FOUND", message: "文件不存在" });
}
const basename = path.split("/").filter(Boolean).pop() ?? path;
if (!isVideoFilename(basename)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "仅支持视频文件" });
}
const item = await mediaItemDao.upsertFromScan({
userId,
mountId,
path,
rawName: basename,
title: basename.replace(/\.[a-z0-9]+$/i, ""),
size,
});
await scrapeService.autoScrape(userId, item.id);
return item;
},
};

View File

@ -0,0 +1,179 @@
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 {
assertSafeWebdavUrl,
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 {
assertSafeWebdavUrl(input.baseUrl);
} catch (e) {
throw new TRPCError({
code: "BAD_REQUEST",
message: e instanceof Error ? e.message : "挂载地址不合法",
});
}
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 (e) {
if (e instanceof TRPCError) throw e;
const msg = e instanceof Error ? e.message : "";
if (/unique|UNIQUE/i.test(msg)) {
throw new TRPCError({ code: "CONFLICT", message: "挂载名称已存在" });
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "创建挂载失败",
cause: e,
});
}
},
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) {
try {
assertSafeWebdavUrl(input.baseUrl);
} catch (e) {
throw new TRPCError({
code: "BAD_REQUEST",
message: e instanceof Error ? e.message : "挂载地址不合法",
});
}
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 {
assertSafeWebdavUrl(input.baseUrl);
} catch (e) {
return { ok: false, message: e instanceof Error ? e.message : "地址不合法" };
}
try {
const client = createWebdav({
baseUrl: input.baseUrl,
username: input.username,
password: input.password,
});
await listDirectory(client, input.rootPath || "/");
return { ok: true, message: "连接成功" };
} catch {
return { ok: false, 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 {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "读取目录失败",
});
}
},
};

View File

@ -0,0 +1,32 @@
import { TRPCError } from "@trpc/server";
import { mediaItemDao, playbackProgressDao } from "@app/dao";
import type { PlaybackReportInput } from "@app/types";
export const playbackService = {
async report(userId: string, input: PlaybackReportInput) {
const item = await mediaItemDao.getByIdForUser(input.mediaItemId, userId);
if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "媒体不存在" });
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,
};
},
};

View File

@ -0,0 +1,21 @@
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,
},
});
});

View File

@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { buildSearchQuery, parseEpisodeFromFilename } 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");
});
});

View File

@ -0,0 +1,135 @@
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;
}
// 只包网络搜索:DB 写失败必须向外抛,避免被误标 failed 后吞掉
let hits: BangumiSearchHit[];
try {
hits = await bangumiSearch(q);
} catch {
await mediaItemDao.updateScrape(mediaItemId, userId, {
scrapeStatus: "failed",
scrapedAt: new Date(),
});
return;
}
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),
});
},
};

View File

@ -0,0 +1,15 @@
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();
});
});

View File

@ -0,0 +1,28 @@
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
function key(): Buffer {
const hex = process.env["MEDIA_SECRET_KEY"];
if (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");
}

View File

@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { resolveContentType } from "./stream.service.js";
describe("resolveContentType", () => {
it("keeps existing mime", () => {
expect(resolveContentType("video/mp4", "a.mkv")).toBe("video/mp4");
});
it("maps common video extensions", () => {
expect(resolveContentType(null, "/m/a.mp4")).toBe("video/mp4");
expect(resolveContentType("", "/m/a.m4v")).toBe("video/mp4");
expect(resolveContentType(undefined, "/m/a.mkv")).toBe("video/x-matroska");
expect(resolveContentType(null, "/m/a.webm")).toBe("video/webm");
});
it("falls back to video/<ext> or octet-stream", () => {
expect(resolveContentType(null, "/m/a.avi")).toBe("video/avi");
expect(resolveContentType(null, "/m/noext")).toBe("application/octet-stream");
});
});

View File

@ -0,0 +1,128 @@
import { mediaItemDao, mountDao } from "@app/dao";
import { decryptSecret } from "./secret.js";
import { createWebdav } from "./webdav-client.js";
export type RemoteFileStream = {
status: number;
headers: Headers;
body: ReadableStream<Uint8Array> | null;
};
const MIME_BY_EXT: Record<string, string> = {
mp4: "video/mp4",
m4v: "video/mp4",
mkv: "video/x-matroska",
webm: "video/webm",
};
/** scan/ensurePath 不写 mime;按扩展名回落,保证播放器拿到可识别的 Content-Type */
export function resolveContentType(mime: string | null | undefined, path: string): string {
if (mime) return mime;
const name = path.split("/").pop() ?? path;
const dot = name.lastIndexOf(".");
const ext = dot >= 0 ? name.slice(dot + 1).toLowerCase() : "";
if (!ext) return "application/octet-stream";
return MIME_BY_EXT[ext] ?? `video/${ext}`;
}
type ParsedRange = { start: number; end: number } | "unsatisfiable" | null;
/**
* 解析单段 bytes Range(RFC 9110):
* - null = 语法非法/不支持的形态 → 调用方忽略并回退 200 全量
* - "unsatisfiable" = 语义不可满足 → 416
*/
function parseBytesRange(header: string, size: number): ParsedRange {
const m = /^bytes=(\d*)-(\d*)$/i.exec(header.trim());
if (!m) return null;
const first = m[1] ?? "";
const second = m[2] ?? "";
if (!first && !second) return null;
if (!first) {
// 后缀语法 bytes=-N:最后 N 字节;N=0 不可满足
const suffix = Number(second);
if (suffix === 0) return "unsatisfiable";
return { start: Math.max(0, size - suffix), end: size - 1 };
}
const start = Number(first);
if (start >= size) return "unsatisfiable";
if (!second) return { start, end: size - 1 };
const end = Number(second);
if (end < start) return "unsatisfiable";
return { start, end: Math.min(end, size - 1) };
}
/** 打开远端媒体的 Range 流;webdav 细节只在本层出现,web 只拿 Headers + ReadableStream。 */
export async function openRemoteFileStream(
userId: string,
mediaItemId: string,
rangeHeader: string | null,
): Promise<RemoteFileStream> {
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
if (!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", resolveContentType(item.mime, item.path));
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 && item.size > 0) {
const parsed = parseBytesRange(rangeHeader, item.size);
if (parsed === "unsatisfiable") {
headers.set("Content-Range", `bytes */${item.size}`);
return { status: 416, headers, body: null };
}
if (parsed) {
start = parsed.start;
end = parsed.end;
status = 206;
headers.set("Content-Range", `bytes ${start}-${end}/${item.size}`);
headers.set("Content-Length", String(end - start + 1));
}
// parsed === null(非法 Range):忽略并回退 200,下方补 Content-Length
}
if (status === 200 && item.size > 0) {
headers.set("Content-Length", String(item.size));
}
// createReadStream 同步返回 ReadableLike,range 选项嵌套在 { range } 内
const nodeStream =
item.size > 0
? client.createReadStream(abs, { range: { start, end } })
: client.createReadStream(abs);
// ReadableLike 类型未声明 pause/resume,但实现是 Node Readable(data 监听后进入 flowing 模式)
const src = nodeStream as typeof nodeStream & { pause(): void; resume(): void };
// pull 驱动 + desiredSize 背压:队列满时 pause,pull(消费者取走)时 resume
const body = new ReadableStream<Uint8Array>({
start(controller) {
src.on("data", (chunk: Buffer | string) => {
controller.enqueue(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
if ((controller.desiredSize ?? 1) <= 0) src.pause();
});
src.on("end", () => controller.close());
src.on("error", (err: unknown) => controller.error(err));
},
pull() {
src.resume();
},
cancel() {
src.destroy();
},
});
return { status, headers, body };
}

View File

@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { assertSafeWebdavUrl, isVideoFilename, joinWebdavPath } 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);
});
});
describe("assertSafeWebdavUrl", () => {
it("allows lan https/http", () => {
expect(() => assertSafeWebdavUrl("https://dav.example.com/dav")).not.toThrow();
expect(() => assertSafeWebdavUrl("http://192.168.1.10:5244/dav")).not.toThrow();
});
it("rejects loopback and metadata", () => {
expect(() => assertSafeWebdavUrl("http://localhost:5244")).toThrow();
expect(() => assertSafeWebdavUrl("http://127.0.0.1/dav")).toThrow();
expect(() => assertSafeWebdavUrl("http://169.254.169.254/")).toThrow();
expect(() => assertSafeWebdavUrl("ftp://x.example/")).toThrow();
expect(() => assertSafeWebdavUrl("not-a-url")).toThrow();
});
});

View File

@ -0,0 +1,68 @@
import { createClient, type WebDAVClient } from "webdav";
import { isVideoFilename } from "@app/types";
export { isVideoFilename };
/**
* SSRF 基线:仅允许 http(s);拒绝 localhost/回环/链路本地(云 metadata)。
* 局域网私网段仍允许(自部署 WebDAV 常见);公网部署需额外网段策略。
*/
export function assertSafeWebdavUrl(baseUrl: string): void {
let u: URL;
try {
u = new URL(baseUrl);
} catch {
throw new Error("挂载地址不是合法 URL");
}
if (u.protocol !== "http:" && u.protocol !== "https:") {
throw new Error("挂载地址仅支持 http/https");
}
const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, "");
if (
host === "localhost" ||
host.endsWith(".localhost") ||
host === "0.0.0.0" ||
host === "::1" ||
host === "0" ||
/^127\./.test(host) ||
/^169\.254\./.test(host)
) {
throw new Error("挂载地址不允许指向本机或链路本地");
}
}
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);
return items.map((item) => ({
filename: item.filename,
basename: item.basename,
type: item.type,
size: Number(item.size),
}));
}

View File

@ -14,5 +14,6 @@
"esModuleInterop": true,
"types": ["node"]
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

View File

@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
// 纯函数单测不需要真实数据库,但 import @app/dao 会连带初始化 libsql client。
// 用内存 sqlite 占位,避免要求调用方先配置 TURSO_DATABASE_URL。
export default defineConfig({
test: {
env: {
TURSO_DATABASE_URL: "file::memory:",
},
},
});

View File

@ -14,12 +14,14 @@
"scripts": {
"build": "tsc --project tsconfig.build.json",
"typecheck": "tsc --noEmit",
"lint": "biome check ."
"lint": "biome check .",
"test": "vitest run"
},
"dependencies": {
"zod": "^4.6.5"
},
"devDependencies": {
"typescript": "^7.0.2"
"typescript": "^7.0.2",
"vitest": "^5.0.1"
}
}

View File

@ -1,2 +1,4 @@
/** `@app/types` 入口:统一导出 Zod 入参 schema 与跨层共享 DTO。 */
export * from "./schemas.js";
export * from "./media-schemas.js";
export * from "./media-fs.js";

View File

@ -0,0 +1,21 @@
/** 视频扩展名与文件名判定(types 纯函数,Web 与 WebDAV 客户端共用)。 */
export const VIDEO_EXTENSIONS = [
"mp4",
"mkv",
"webm",
"avi",
"mov",
"m4v",
"ts",
"flv",
"wmv",
"mpg",
"mpeg",
] as const;
export function isVideoFilename(name: string): boolean {
const i = name.lastIndexOf(".");
if (i < 0) return false;
const ext = name.slice(i + 1).toLowerCase();
return (VIDEO_EXTENSIONS as readonly string[]).includes(ext);
}

View File

@ -0,0 +1,40 @@
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);
});
});

View File

@ -0,0 +1,101 @@
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 }),
ensurePath: z.object({
mountId: id,
path: z.string().min(1).max(2000),
}),
};
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(1).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 LibraryEnsurePathInput = z.infer<typeof librarySchemas.ensurePath>;
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;
};

View File

@ -6,5 +6,6 @@
"rootDir": "./src",
"composite": true
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

414
yarn.lock
View File

@ -106,6 +106,8 @@ __metadata:
tsx: "npm:^4.19.4"
typescript: "npm:^7.0.2"
vite: "npm:^8.3.0"
vitest: "npm:^5.0.1"
webdav: "npm:^5.11.0"
languageName: unknown
linkType: soft
@ -114,6 +116,7 @@ __metadata:
resolution: "@app/types@workspace:packages/types"
dependencies:
typescript: "npm:^7.0.2"
vitest: "npm:^5.0.1"
zod: "npm:^4.6.5"
languageName: unknown
linkType: soft
@ -172,6 +175,7 @@ __metadata:
"@trpc/server": "npm:^11.19.0"
"@types/react": "npm:^19.1.4"
"@types/react-dom": "npm:^19.1.5"
artplayer: "npm:^5.2.2"
astro: "npm:^7.3.3"
gsap: "npm:^3.15.0"
lenis: "npm:^1.3.26"
@ -812,6 +816,18 @@ __metadata:
languageName: node
linkType: hard
"@buttercup/fetch@npm:^0.2.1":
version: 0.2.1
resolution: "@buttercup/fetch@npm:0.2.1"
dependencies:
node-fetch: "npm:^3.3.0"
dependenciesMeta:
node-fetch:
optional: true
checksum: 10c0/e18cc830d7700d592812d9321d69d52537ae451eb3881021ed0a544d523e0a827ab1b8746823faa1166e17fd3257cbaa63ae9cc304ed534984e615c6c21f3944
languageName: node
linkType: hard
"@capsizecss/unpack@npm:^4.0.0":
version: 4.0.1
resolution: "@capsizecss/unpack@npm:4.0.1"
@ -2231,7 +2247,7 @@ __metadata:
languageName: node
linkType: hard
"@jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.28":
"@jridgewell/trace-mapping@npm:0.3.31, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.28":
version: 0.3.31
resolution: "@jridgewell/trace-mapping@npm:0.3.31"
dependencies:
@ -2379,6 +2395,13 @@ __metadata:
languageName: node
linkType: hard
"@nodable/entities@npm:^3.0.0":
version: 3.0.0
resolution: "@nodable/entities@npm:3.0.0"
checksum: 10c0/5e57422ce08d9f83a7ad09d8f90953e2e63b3274c3f941bf7ddf64f290473e70d47acbd6c8b9e60169c2a8c5c2424c4131bdd99b937792413f55a4a146f76658
languageName: node
linkType: hard
"@oslojs/encoding@npm:^1.1.0":
version: 1.1.0
resolution: "@oslojs/encoding@npm:1.1.0"
@ -4184,6 +4207,26 @@ __metadata:
languageName: node
linkType: hard
"@vitest/mocker@npm:5.0.1":
version: 5.0.1
resolution: "@vitest/mocker@npm:5.0.1"
dependencies:
"@jridgewell/trace-mapping": "npm:0.3.31"
"@vitest/spy": "npm:5.0.1"
estree-walker: "npm:^3.0.3"
magic-string: "npm:^1.2.3"
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
checksum: 10c0/21f0456c7452d7f65449fc0f43f1ccbf222d353eb11fcf511b78b5b8c4ae38ac9ca286b56936d91856c483575cd86688f1b6dc00f02ffd85ed1108b12cf219cd
languageName: node
linkType: hard
"@vitest/pretty-format@npm:3.2.4":
version: 3.2.4
resolution: "@vitest/pretty-format@npm:3.2.4"
@ -4202,6 +4245,13 @@ __metadata:
languageName: node
linkType: hard
"@vitest/spy@npm:5.0.1":
version: 5.0.1
resolution: "@vitest/spy@npm:5.0.1"
checksum: 10c0/88838f6dda5237f0a40c365598ce7112922611e9157b390ca90c11760ae8697c37214f621263bfbe12e2bd27234df577cbbecc93de003ced366d23be24383c37
languageName: node
linkType: hard
"@vitest/utils@npm:3.2.4":
version: 3.2.4
resolution: "@vitest/utils@npm:3.2.4"
@ -4404,6 +4454,13 @@ __metadata:
languageName: node
linkType: hard
"anynum@npm:^1.0.1":
version: 1.0.1
resolution: "anynum@npm:1.0.1"
checksum: 10c0/cfda92c9215722fc4b15faa33d0eb3d5dfd28fba6cb67c1f50d214feaa7ba6497814a3e110777853585de6d91c8c962a1ae425b637d3598d9f9d8f6f6802e5bb
languageName: node
linkType: hard
"app-template@workspace:.":
version: 0.0.0-use.local
resolution: "app-template@workspace:."
@ -4462,6 +4519,15 @@ __metadata:
languageName: node
linkType: hard
"artplayer@npm:^5.2.2":
version: 5.4.0
resolution: "artplayer@npm:5.4.0"
dependencies:
option-validator: "npm:^2.0.6"
checksum: 10c0/022e661a5757d5986e043aa112ee6a28369750559c69dc99e9b12c9d9c26605af3437e8a379aa7583abfafd4ae313c3ba7011c3d93ce0a85fccdaf548f67eb95
languageName: node
linkType: hard
"assertion-error@npm:^2.0.1":
version: 2.0.1
resolution: "assertion-error@npm:2.0.1"
@ -4578,6 +4644,13 @@ __metadata:
languageName: node
linkType: hard
"base-64@npm:^1.0.0":
version: 1.0.0
resolution: "base-64@npm:1.0.0"
checksum: 10c0/d886cb3236cee0bed9f7075675748b59b32fad623ddb8ce1793c790306aa0f76a03238cad4b3fb398abda6527ce08a5588388533a4ccade0b97e82b9da660e28
languageName: node
linkType: hard
"baseline-browser-mapping@npm:^2.11.23":
version: 2.11.25
resolution: "baseline-browser-mapping@npm:2.11.25"
@ -4634,6 +4707,13 @@ __metadata:
languageName: node
linkType: hard
"byte-length@npm:^1.0.2":
version: 1.0.2
resolution: "byte-length@npm:1.0.2"
checksum: 10c0/98778b938318494c2eadedf83b415e63da406d905575101eb102b7eefb5fafbbe21fbe83001914283664bb2fb93bd46f99245af2e8420a928ba90ffbb58041a1
languageName: node
linkType: hard
"callsites@npm:^3.0.0":
version: 3.1.0
resolution: "callsites@npm:3.1.0"
@ -4668,6 +4748,13 @@ __metadata:
languageName: node
linkType: hard
"chai@npm:^6.2.2":
version: 6.2.2
resolution: "chai@npm:6.2.2"
checksum: 10c0/e6c69e5f0c11dffe6ea13d0290936ebb68fcc1ad688b8e952e131df6a6d5797d5e860bc55cef1aca2e950c3e1f96daf79e9d5a70fb7dbaab4e46355e2635ed53
languageName: node
linkType: hard
"character-entities-html4@npm:^2.0.0":
version: 2.1.0
resolution: "character-entities-html4@npm:2.1.0"
@ -4689,6 +4776,13 @@ __metadata:
languageName: node
linkType: hard
"charenc@npm:0.0.2":
version: 0.0.2
resolution: "charenc@npm:0.0.2"
checksum: 10c0/a45ec39363a16799d0f9365c8dd0c78e711415113c6f14787a22462ef451f5013efae8a28f1c058f81fc01f2a6a16955f7a5fd0cd56247ce94a45349c89877d8
languageName: node
linkType: hard
"check-error@npm:^2.1.1":
version: 2.1.3
resolution: "check-error@npm:2.1.3"
@ -4893,6 +4987,13 @@ __metadata:
languageName: node
linkType: hard
"crypt@npm:0.0.2":
version: 0.0.2
resolution: "crypt@npm:0.0.2"
checksum: 10c0/adbf263441dd801665d5425f044647533f39f4612544071b1471962209d235042fb703c27eea2795c7c53e1dfc242405173003f83cf4f4761a633d11f9653f18
languageName: node
linkType: hard
"css-select@npm:^6.0.0":
version: 6.0.0
resolution: "css-select@npm:6.0.0"
@ -5320,6 +5421,13 @@ __metadata:
languageName: node
linkType: hard
"entities@npm:^6.0.1":
version: 6.0.1
resolution: "entities@npm:6.0.1"
checksum: 10c0/ed836ddac5acb34341094eb495185d527bd70e8632b6c0d59548cbfa23defdbae70b96f9a405c82904efa421230b5b3fd2283752447d737beffd3f3e6ee74414
languageName: node
linkType: hard
"env-paths@npm:^2.2.0, env-paths@npm:^2.2.1":
version: 2.2.1
resolution: "env-paths@npm:2.2.1"
@ -5350,7 +5458,7 @@ __metadata:
languageName: node
linkType: hard
"es-module-lexer@npm:^2.0.0":
"es-module-lexer@npm:^2.0.0, es-module-lexer@npm:^2.3.2":
version: 2.3.2
resolution: "es-module-lexer@npm:2.3.2"
checksum: 10c0/5e7389424c43478439f12f9a6aca1750f6f99afa384fc3de329f4a45f152ab156671055008adaafc74960877abf8cc338aeebf6bed8c21297b146fd6eb7a22f8
@ -5778,6 +5886,13 @@ __metadata:
languageName: node
linkType: hard
"expect-type@npm:^1.4.0":
version: 1.4.0
resolution: "expect-type@npm:1.4.0"
checksum: 10c0/d40d76b8570695d36587beb3cc28494da2ca3ec8f04e67f5622ed2d372d850e401a9adef19c6835e1a8173903f157c79540b34c7b3fbd7cd8ce726cc903c57b7
languageName: node
linkType: hard
"exponential-backoff@npm:^3.1.1":
version: 3.1.3
resolution: "exponential-backoff@npm:3.1.3"
@ -5831,6 +5946,32 @@ __metadata:
languageName: node
linkType: hard
"fast-xml-builder@npm:^1.2.0":
version: 1.3.1
resolution: "fast-xml-builder@npm:1.3.1"
dependencies:
path-expression-matcher: "npm:^1.6.2"
xml-naming: "npm:^0.3.0"
checksum: 10c0/5aaf30465e6ba4ae9780eb3916878d5bd40763a3fd6b8b079a76aabe8f3e9e852327280e7c1be2b63ec141424b6ec61b7ab8acca8b3948779b164b004a294b4c
languageName: node
linkType: hard
"fast-xml-parser@npm:^5.11.1":
version: 5.11.1
resolution: "fast-xml-parser@npm:5.11.1"
dependencies:
"@nodable/entities": "npm:^3.0.0"
fast-xml-builder: "npm:^1.2.0"
is-unsafe: "npm:^2.0.0"
path-expression-matcher: "npm:^1.6.2"
strnum: "npm:^2.4.2"
xml-naming: "npm:^0.3.0"
bin:
fxparser: src/cli/cli.js
checksum: 10c0/04a1cf600130c4fc146e9f5bcb0a0e88354c5b9725debfa849f9028afd90b77a3e4c9339a3a7f5d22d749b74ac78567428eb1385679319fabf4d6f03065248d6
languageName: node
linkType: hard
"fdir@npm:^6.4.4, fdir@npm:^6.5.0":
version: 6.5.0
resolution: "fdir@npm:6.5.0"
@ -6117,6 +6258,13 @@ __metadata:
languageName: node
linkType: hard
"hot-patcher@npm:^2.0.1":
version: 2.0.1
resolution: "hot-patcher@npm:2.0.1"
checksum: 10c0/cabccf0d78b9604cdd73b88d1cfcdff5ae27b501df97ee20131979513cc17c532f4f04605d24ac9225d2c046fc435062cec86b49f9fb3f524b446a296e8c1b62
languageName: node
linkType: hard
"html-escaper@npm:3.0.3":
version: 3.0.3
resolution: "html-escaper@npm:3.0.3"
@ -6196,6 +6344,13 @@ __metadata:
languageName: node
linkType: hard
"is-buffer@npm:~1.1.6":
version: 1.1.6
resolution: "is-buffer@npm:1.1.6"
checksum: 10c0/ae18aa0b6e113d6c490ad1db5e8df9bdb57758382b313f5a22c9c61084875c6396d50bbf49315f5b1926d142d74dfb8d31b40d993a383e0a158b15fea7a82234
languageName: node
linkType: hard
"is-core-module@npm:^2.16.1":
version: 2.17.0
resolution: "is-core-module@npm:2.17.0"
@ -6237,6 +6392,13 @@ __metadata:
languageName: node
linkType: hard
"is-unsafe@npm:^2.0.0":
version: 2.0.2
resolution: "is-unsafe@npm:2.0.2"
checksum: 10c0/3f92f6534e326ae13f4a4733d0d6b9f06f2b7ab80d99c1973eb32b33b00cf9bc7a8505e3260f36209c9109f56a46bb64cc6bd0a27e37fee08cd35892a2b6ae44
languageName: node
linkType: hard
"is-wsl@npm:^2.2.0":
version: 2.2.0
resolution: "is-wsl@npm:2.2.0"
@ -6369,6 +6531,13 @@ __metadata:
languageName: node
linkType: hard
"kind-of@npm:^6.0.3":
version: 6.0.3
resolution: "kind-of@npm:6.0.3"
checksum: 10c0/61cdff9623dabf3568b6445e93e31376bee1cdb93f8ba7033d86022c2a9b1791a1d9510e026e6465ebd701a6dd2f7b0808483ad8838341ac52f003f512e0b4c4
languageName: node
linkType: hard
"kleur@npm:^4.1.5":
version: 4.1.5
resolution: "kleur@npm:4.1.5"
@ -6376,6 +6545,13 @@ __metadata:
languageName: node
linkType: hard
"layerr@npm:^3.0.0":
version: 3.0.0
resolution: "layerr@npm:3.0.0"
checksum: 10c0/320c9b9cf1392c73c9ff8f8d1bb7a782093ac7341fe5d7fea6ebfeea6d785af8e0fc573541431b6ffcb268577f8aea66168757e73a15035568a00ab9e4370705
languageName: node
linkType: hard
"lefthook-darwin-arm64@npm:2.1.14":
version: 2.1.14
resolution: "lefthook-darwin-arm64@npm:2.1.14"
@ -6849,6 +7025,15 @@ __metadata:
languageName: node
linkType: hard
"magic-string@npm:^1.2.3":
version: 1.4.2
resolution: "magic-string@npm:1.4.2"
dependencies:
"@jridgewell/sourcemap-codec": "npm:^1.6.0"
checksum: 10c0/d3e222c202f99ec4861e7316f473f9b1cfe335ebdf38d4f67335f842f3fe248f01ee00fd3e1a8206bcbe9855e0c41515d7c210300d8fcf6eefbb982d79e423c6
languageName: node
linkType: hard
"magicast@npm:^0.5.2":
version: 0.5.5
resolution: "magicast@npm:0.5.5"
@ -6860,6 +7045,17 @@ __metadata:
languageName: node
linkType: hard
"md5@npm:^2.3.0":
version: 2.3.0
resolution: "md5@npm:2.3.0"
dependencies:
charenc: "npm:0.0.2"
crypt: "npm:0.0.2"
is-buffer: "npm:~1.1.6"
checksum: 10c0/14a21d597d92e5b738255fbe7fe379905b8cb97e0a49d44a20b58526a646ec5518c337b817ce0094ca94d3e81a3313879c4c7b510d250c282d53afbbdede9110
languageName: node
linkType: hard
"mdast-util-phrasing@npm:^4.0.0":
version: 4.1.0
resolution: "mdast-util-phrasing@npm:4.1.0"
@ -7024,7 +7220,7 @@ __metadata:
languageName: node
linkType: hard
"minimatch@npm:^9.0.4":
"minimatch@npm:^9.0.4, minimatch@npm:^9.0.9":
version: 9.0.9
resolution: "minimatch@npm:9.0.9"
dependencies:
@ -7093,6 +7289,13 @@ __metadata:
languageName: node
linkType: hard
"nested-property@npm:^4.0.0":
version: 4.0.0
resolution: "nested-property@npm:4.0.0"
checksum: 10c0/7bc0514f3d10460cc07ea27a39ce75f81471a28b8b019d4bfd9eda41dcd92c1fcb291598d7e168ae8bf1324109b36325e3a563d6aa2537d13015bea9258b8b72
languageName: node
linkType: hard
"nlcst-to-string@npm:^4.0.0":
version: 4.0.0
resolution: "nlcst-to-string@npm:4.0.0"
@ -7116,7 +7319,7 @@ __metadata:
languageName: node
linkType: hard
"node-fetch@npm:^3.3.2":
"node-fetch@npm:^3.3.0, node-fetch@npm:^3.3.2":
version: 3.3.2
resolution: "node-fetch@npm:3.3.2"
dependencies:
@ -7188,7 +7391,7 @@ __metadata:
languageName: node
linkType: hard
"obug@npm:^2.1.1":
"obug@npm:^2.1.1, obug@npm:^2.1.4":
version: 2.2.1
resolution: "obug@npm:2.2.1"
checksum: 10c0/8a7c001847d880fa6ba83c7bbee6a80fd271ee628439cd508c8647d53b217abb1759e2d5e4aaeb09a35d0a274bdb225a0a5a78eded3213d63e656153cf411b63
@ -7251,6 +7454,15 @@ __metadata:
languageName: node
linkType: hard
"option-validator@npm:^2.0.6":
version: 2.0.6
resolution: "option-validator@npm:2.0.6"
dependencies:
kind-of: "npm:^6.0.3"
checksum: 10c0/5a78f864e0aab0d104ef456b03c7be55dafe00c83708a9ab9466cbd3bec51503eb29372039c218e06d407f04a045b3e6b30984601df06f459b22b6167135bf87
languageName: node
linkType: hard
"p-limit@npm:^4.0.0":
version: 4.0.0
resolution: "p-limit@npm:4.0.0"
@ -7344,6 +7556,13 @@ __metadata:
languageName: node
linkType: hard
"path-expression-matcher@npm:^1.6.2":
version: 1.6.2
resolution: "path-expression-matcher@npm:1.6.2"
checksum: 10c0/8241302f563de367a81acacd417055a22dd3792a84700569dc2a7888da31aa18eb01131c55f05200c92790b6c45b01fee1984b02540cc5f15726ba1b73a8a075
languageName: node
linkType: hard
"path-key@npm:^3.1.0":
version: 3.1.1
resolution: "path-key@npm:3.1.1"
@ -7358,6 +7577,13 @@ __metadata:
languageName: node
linkType: hard
"path-posix@npm:^1.0.0":
version: 1.0.0
resolution: "path-posix@npm:1.0.0"
checksum: 10c0/00fbadb9b60fb513f316f92e0b5535e55d832f4f20067586d151f6d7bed57178dec31b1a0f514694500a9a1f2b69798c066a3cdcf0b0289cfee63e39845bfd02
languageName: node
linkType: hard
"path-scurry@npm:^1.11.1":
version: 1.11.1
resolution: "path-scurry@npm:1.11.1"
@ -7458,6 +7684,13 @@ __metadata:
languageName: node
linkType: hard
"querystringify@npm:^2.1.1":
version: 2.2.0
resolution: "querystringify@npm:2.2.0"
checksum: 10c0/3258bc3dbdf322ff2663619afe5947c7926a6ef5fb78ad7d384602974c467fadfc8272af44f5eb8cddd0d011aae8fabf3a929a8eee4b86edcc0a21e6bd10f9aa
languageName: node
linkType: hard
"radix3@npm:^1.1.2":
version: 1.1.2
resolution: "radix3@npm:1.1.2"
@ -7658,6 +7891,13 @@ __metadata:
languageName: node
linkType: hard
"requires-port@npm:^1.0.0":
version: 1.0.0
resolution: "requires-port@npm:1.0.0"
checksum: 10c0/b2bfdd09db16c082c4326e573a82c0771daaf7b53b9ce8ad60ea46aa6e30aaf475fe9b164800b89f93b748d2c234d8abff945d2551ba47bf5698e04cd7713267
languageName: node
linkType: hard
"resolve-from@npm:^4.0.0":
version: 4.0.0
resolution: "resolve-from@npm:4.0.0"
@ -8128,6 +8368,13 @@ __metadata:
languageName: node
linkType: hard
"siginfo@npm:^2.0.0":
version: 2.0.0
resolution: "siginfo@npm:2.0.0"
checksum: 10c0/3def8f8e516fbb34cb6ae415b07ccc5d9c018d85b4b8611e3dc6f8be6d1899f693a4382913c9ed51a06babb5201639d76453ab297d1c54a456544acf5c892e34
languageName: node
linkType: hard
"signal-exit@npm:^4.0.1":
version: 4.1.0
resolution: "signal-exit@npm:4.1.0"
@ -8208,6 +8455,13 @@ __metadata:
languageName: node
linkType: hard
"stackback@npm:0.0.2":
version: 0.0.2
resolution: "stackback@npm:0.0.2"
checksum: 10c0/89a1416668f950236dd5ac9f9a6b2588e1b9b62b1b6ad8dff1bfc5d1a15dbf0aafc9b52d2226d00c28dffff212da464eaeebfc6b7578b9d180cef3e3782c5983
languageName: node
linkType: hard
"statuses@npm:^2.0.2, statuses@npm:~2.0.2":
version: 2.0.2
resolution: "statuses@npm:2.0.2"
@ -8215,6 +8469,13 @@ __metadata:
languageName: node
linkType: hard
"std-env@npm:^4.2.0":
version: 4.2.0
resolution: "std-env@npm:4.2.0"
checksum: 10c0/40ac525ce7b7c556abc332a7376f14356eeb1a7f17f6ff9a003eb9f52326ff1f3745d3e1b43452675b1ec6fcc319f1b1d6f3b0d386cf3f91058479ad883cff69
languageName: node
linkType: hard
"storybook@npm:^9.1.10":
version: 9.1.20
resolution: "storybook@npm:9.1.20"
@ -8336,6 +8597,15 @@ __metadata:
languageName: node
linkType: hard
"strnum@npm:^2.4.2":
version: 2.4.2
resolution: "strnum@npm:2.4.2"
dependencies:
anynum: "npm:^1.0.1"
checksum: 10c0/71a94dcc12cf3187e10089ed2ddcf7c289fed168ce4a33ee393bbd009a9199c237e3ef71597f3c91fac4203dd1e675c090eb4131226fd70af6fa2a31b0175de2
languageName: node
linkType: hard
"supports-preserve-symlinks-flag@npm:^1.0.0":
version: 1.0.0
resolution: "supports-preserve-symlinks-flag@npm:1.0.0"
@ -8408,6 +8678,13 @@ __metadata:
languageName: node
linkType: hard
"tinybench@npm:6.1.4":
version: 6.1.4
resolution: "tinybench@npm:6.1.4"
checksum: 10c0/7a815318a02270a98247298ddd289b75c841725606c977f0b74749bc5ec811a44998267ccb60bd3deb23b551dbfaf4d979eb08e9b04fbc87ff571f9b39cc61c3
languageName: node
linkType: hard
"tinyclip@npm:^0.1.12":
version: 0.1.15
resolution: "tinyclip@npm:0.1.15"
@ -8415,6 +8692,13 @@ __metadata:
languageName: node
linkType: hard
"tinyexec@npm:1.3.0":
version: 1.3.0
resolution: "tinyexec@npm:1.3.0"
checksum: 10c0/e9b89f97489d2aab2cef408da279e6b32547e738d1275032ccb8fd0028a006d93eb70fc51c6cffd9fc2f5aca6c2a273d8b6f73b52d46ee5116da6b94969ef958
languageName: node
linkType: hard
"tinyexec@npm:^1.0.0, tinyexec@npm:^1.0.4":
version: 1.3.1
resolution: "tinyexec@npm:1.3.1"
@ -8981,6 +9265,23 @@ __metadata:
languageName: node
linkType: hard
"url-join@npm:^5.0.0":
version: 5.0.0
resolution: "url-join@npm:5.0.0"
checksum: 10c0/ed2b166b4b5a98adcf6828a48b6bd6df1dac4c8a464a73cf4d8e2457ed410dd8da6be0d24855b86026cd7f5c5a3657c1b7b2c7a7c5b8870af17635a41387b04c
languageName: node
linkType: hard
"url-parse@npm:^1.5.10":
version: 1.5.10
resolution: "url-parse@npm:1.5.10"
dependencies:
querystringify: "npm:^2.1.1"
requires-port: "npm:^1.0.0"
checksum: 10c0/bd5aa9389f896974beb851c112f63b466505a04b4807cea2e5a3b7092f6fbb75316f0491ea84e44f66fed55f1b440df5195d7e3a8203f64fcefa19d182f5be87
languageName: node
linkType: hard
"use-callback-ref@npm:^1.3.3":
version: 1.3.3
resolution: "use-callback-ref@npm:1.3.3"
@ -9218,6 +9519,67 @@ __metadata:
languageName: node
linkType: hard
"vitest@npm:^5.0.1":
version: 5.0.1
resolution: "vitest@npm:5.0.1"
dependencies:
"@types/chai": "npm:^5.2.2"
"@vitest/mocker": "npm:5.0.1"
chai: "npm:^6.2.2"
es-module-lexer: "npm:^2.3.2"
expect-type: "npm:^1.4.0"
magic-string: "npm:^1.2.3"
obug: "npm:^2.1.4"
picomatch: "npm:^4.0.7"
std-env: "npm:^4.2.0"
tinybench: "npm:6.1.4"
tinyexec: "npm:1.3.0"
tinyglobby: "npm:^0.2.17"
why-is-node-running: "npm:^2.3.0"
peerDependencies:
"@edge-runtime/vm": "*"
"@opentelemetry/api": ^1.9.0
"@types/node": ^22.0.0 || >=24.0.0
"@vitest/browser-playwright": 5.0.1
"@vitest/browser-preview": 5.0.1
"@vitest/browser-webdriverio": ^5.0.0-beta.5 || >=5.0.0
"@vitest/coverage-istanbul": 5.0.1
"@vitest/coverage-v8": 5.0.1
"@vitest/ui": 5.0.1
happy-dom: "*"
jsdom: "*"
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
"@edge-runtime/vm":
optional: true
"@opentelemetry/api":
optional: true
"@types/node":
optional: true
"@vitest/browser-playwright":
optional: true
"@vitest/browser-preview":
optional: true
"@vitest/browser-webdriverio":
optional: true
"@vitest/coverage-istanbul":
optional: true
"@vitest/coverage-v8":
optional: true
"@vitest/ui":
optional: true
happy-dom:
optional: true
jsdom:
optional: true
vite:
optional: false
bin:
vitest: ./vitest.mjs
checksum: 10c0/1b49fe8eb7274ffd43ca5a29cccbcc942cf991488020a74114f3a725a1168c549d47820715b8ed3f521a0cd9ccc0013ab9511cc6e0f8e0a5bdfb751e3ddc4d2d
languageName: node
linkType: hard
"volar-service-css@npm:0.0.71":
version: 0.0.71
resolution: "volar-service-css@npm:0.0.71"
@ -9456,6 +9818,29 @@ __metadata:
languageName: node
linkType: hard
"webdav@npm:^5.11.0":
version: 5.11.0
resolution: "webdav@npm:5.11.0"
dependencies:
"@buttercup/fetch": "npm:^0.2.1"
"@nodable/entities": "npm:^3.0.0"
base-64: "npm:^1.0.0"
byte-length: "npm:^1.0.2"
entities: "npm:^6.0.1"
fast-xml-parser: "npm:^5.11.1"
hot-patcher: "npm:^2.0.1"
layerr: "npm:^3.0.0"
md5: "npm:^2.3.0"
minimatch: "npm:^9.0.9"
nested-property: "npm:^4.0.0"
node-fetch: "npm:^3.3.2"
path-posix: "npm:^1.0.0"
url-join: "npm:^5.0.0"
url-parse: "npm:^1.5.10"
checksum: 10c0/e980ee6b34ec0f0a72023c91f699f528d07d486241c286e6a691b019ef3954474c00625f1b4acd1e7203e1329d412582db7c41b4c11f9c1b7628e4aaaee585b8
languageName: node
linkType: hard
"webpack-virtual-modules@npm:^0.6.2":
version: 0.6.2
resolution: "webpack-virtual-modules@npm:0.6.2"
@ -9496,6 +9881,18 @@ __metadata:
languageName: node
linkType: hard
"why-is-node-running@npm:^2.3.0":
version: 2.3.0
resolution: "why-is-node-running@npm:2.3.0"
dependencies:
siginfo: "npm:^2.0.0"
stackback: "npm:0.0.2"
bin:
why-is-node-running: cli.js
checksum: 10c0/1cde0b01b827d2cf4cb11db962f3958b9175d5d9e7ac7361d1a7b0e2dc6069a263e69118bd974c4f6d0a890ef4eedfe34cf3d5167ec14203dbc9a18620537054
languageName: node
linkType: hard
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version: 7.0.0
resolution: "wrap-ansi@npm:7.0.0"
@ -9544,6 +9941,13 @@ __metadata:
languageName: node
linkType: hard
"xml-naming@npm:^0.3.0":
version: 0.3.0
resolution: "xml-naming@npm:0.3.0"
checksum: 10c0/48bfc4fe888cdf1c3eceb7853632ebce59e4aa71f0ae33c3f9ce1fbd3213caad293457de0a311114491f0d4efcfba70977dcba3a4f66dfed8c92edbb938056bf
languageName: node
linkType: hard
"xxhash-wasm@npm:^1.1.0":
version: 1.1.0
resolution: "xxhash-wasm@npm:1.1.0"