Compare commits
No commits in common. "a7873891f2702668a22881498bfdf5528f9d13a8" and "a5ba7124dc8d021a2a46975176898c1dab0d2017" have entirely different histories.
a7873891f2
...
a5ba7124dc
16
.env.example
16
.env.example
|
|
@ -8,19 +8,3 @@ 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/群晖;公网部署请自行加出站策略。
|
||||
|
|
|
|||
|
|
@ -27,6 +27,3 @@ storybook-static/
|
|||
**/storybook-static/
|
||||
|
||||
.DS_Store
|
||||
.superpowers/
|
||||
.worktrees/
|
||||
worktrees/
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@
|
|||
"@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",
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,336 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -20,8 +20,6 @@ export interface Props {
|
|||
switchHref?: string;
|
||||
switchLabel?: string;
|
||||
seo?: PageSeoInput;
|
||||
/** 媒体页只保留 AppShell 侧栏时隐藏模板 side-rail(含左让位) */
|
||||
hideSideRail?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
|
|
@ -32,7 +30,6 @@ const {
|
|||
switchHref,
|
||||
switchLabel,
|
||||
seo,
|
||||
hideSideRail = false,
|
||||
} = Astro.props;
|
||||
|
||||
const brandName = brand ?? t(locale, "meta.brand");
|
||||
|
|
@ -128,8 +125,6 @@ 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>
|
||||
|
||||
{
|
||||
!hideSideRail && (
|
||||
<aside class="side-rail" aria-label={t(locale, "nav.primary")}>
|
||||
<a href={brandHref} class="side-rail__brand" aria-label={brandName}>
|
||||
<span
|
||||
|
|
@ -146,22 +141,22 @@ const brandInitial = locale === "zh-CN" ? "模" : "A";
|
|||
<span class="u-mono">00</span>
|
||||
<span>{t(locale, "nav.home")}</span>
|
||||
</a>
|
||||
{nav.map((item) => (
|
||||
{
|
||||
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:list={["site-shell", { "site-shell--no-rail": hideSideRail }]}>
|
||||
<div class="site-shell">
|
||||
<header class="site-header">
|
||||
<div class="site-header__inner">
|
||||
<a href={brandHref} class="site-header__brand md:hidden" aria-label={brandName}>
|
||||
|
|
@ -197,7 +192,7 @@ const brandInitial = locale === "zh-CN" ? "模" : "A";
|
|||
</a>
|
||||
)
|
||||
}
|
||||
<span class={hideSideRail ? "" : "md:hidden"}>
|
||||
<span class="md:hidden">
|
||||
<ThemeSwitch client:visible />
|
||||
</span>
|
||||
<UserMenu client:visible locale={locale} />
|
||||
|
|
|
|||
|
|
@ -1,95 +0,0 @@
|
|||
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();
|
||||
};
|
||||
}
|
||||
|
|
@ -6,15 +6,7 @@ import { authService } from "@app/trpc";
|
|||
const SKIP_PREFIX = ["/api", "/_astro", "/favicon", "/robots", "/sitemap", "/llms"];
|
||||
|
||||
/** 需登录的基础路径(无 locale 前缀)。按需增删。 */
|
||||
const PROTECTED_BASES = [
|
||||
"/dashboard",
|
||||
"/settings",
|
||||
"/library",
|
||||
"/browse",
|
||||
"/watch",
|
||||
"/mounts",
|
||||
"/danmaku",
|
||||
] as const;
|
||||
const PROTECTED_BASES = ["/dashboard", "/settings"] as const;
|
||||
|
||||
const LOCALE_SEGMENTS = new Set(["zh-CN", "en"]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
---
|
||||
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} />
|
||||
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
---
|
||||
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} />
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
---
|
||||
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} />
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
---
|
||||
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} />
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
---
|
||||
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} />
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
/** 认证后的 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 });
|
||||
}
|
||||
};
|
||||
|
|
@ -471,18 +471,12 @@ 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;
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
---
|
||||
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>
|
||||
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
---
|
||||
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>
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
---
|
||||
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>
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
---
|
||||
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>
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
---
|
||||
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
|
|
@ -1,219 +0,0 @@
|
|||
# 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 |
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
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;
|
||||
},
|
||||
};
|
||||
|
|
@ -5,9 +5,3 @@ 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";
|
||||
|
|
|
|||
|
|
@ -1,124 +0,0 @@
|
|||
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;
|
||||
},
|
||||
};
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
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)));
|
||||
},
|
||||
};
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
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;
|
||||
},
|
||||
};
|
||||
|
|
@ -9,11 +9,6 @@ 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":
|
||||
|
|
@ -36,80 +31,6 @@ 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",
|
||||
|
|
|
|||
|
|
@ -11,11 +11,6 @@ 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 脚手架。登录后可进入仪表盘查看用户列表示例。",
|
||||
|
|
@ -37,79 +32,6 @@ 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": "使用邮箱与密码继续",
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
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;
|
||||
|
|
@ -5,11 +5,3 @@ 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";
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
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;
|
||||
|
|
@ -18,7 +18,6 @@
|
|||
"scripts": {
|
||||
"build": "vite build && tsc -p tsconfig.dts.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"lint": "biome check .",
|
||||
"dev": "tsx watch src/server.ts"
|
||||
},
|
||||
|
|
@ -28,14 +27,12 @@
|
|||
"@app/models": "workspace:*",
|
||||
"@app/types": "workspace:*",
|
||||
"@trpc/server": "^11.19.0",
|
||||
"drizzle-orm": "^0.45.3",
|
||||
"webdav": "^5.11.0"
|
||||
"drizzle-orm": "^0.45.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.29",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.0",
|
||||
"vitest": "^5.0.1"
|
||||
"vite": "^8.3.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
// 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";
|
||||
|
|
@ -28,22 +27,3 @@ 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";
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ 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({
|
||||
|
|
@ -35,7 +34,6 @@ export const userRouter = t.router({
|
|||
export const appRouter = t.router({
|
||||
user: userRouter,
|
||||
auth: authRouter,
|
||||
media: mediaRouter,
|
||||
});
|
||||
|
||||
// ─── Type re-exports ────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,90 +0,0 @@
|
|||
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),
|
||||
});
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
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 & <b></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>');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** 开放网络 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 };
|
||||
},
|
||||
};
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
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;
|
||||
},
|
||||
};
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
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: "读取目录失败",
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
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),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
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");
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
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 };
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
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),
|
||||
}));
|
||||
}
|
||||
|
|
@ -14,6 +14,5 @@
|
|||
"esModuleInterop": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// 纯函数单测不需要真实数据库,但 import @app/dao 会连带初始化 libsql client。
|
||||
// 用内存 sqlite 占位,避免要求调用方先配置 TURSO_DATABASE_URL。
|
||||
export default defineConfig({
|
||||
test: {
|
||||
env: {
|
||||
TURSO_DATABASE_URL: "file::memory:",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -14,14 +14,12 @@
|
|||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome check .",
|
||||
"test": "vitest run"
|
||||
"lint": "biome check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^5.0.1"
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,2 @@
|
|||
/** `@app/types` 入口:统一导出 Zod 入参 schema 与跨层共享 DTO。 */
|
||||
export * from "./schemas.js";
|
||||
export * from "./media-schemas.js";
|
||||
export * from "./media-fs.js";
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
/** 视频扩展名与文件名判定(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);
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
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;
|
||||
};
|
||||
|
|
@ -6,6 +6,5 @@
|
|||
"rootDir": "./src",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
|
|||
414
yarn.lock
414
yarn.lock
|
|
@ -106,8 +106,6 @@ __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
|
||||
|
||||
|
|
@ -116,7 +114,6 @@ __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
|
||||
|
|
@ -175,7 +172,6 @@ __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"
|
||||
|
|
@ -816,18 +812,6 @@ __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"
|
||||
|
|
@ -2247,7 +2231,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@jridgewell/trace-mapping@npm:0.3.31, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.28":
|
||||
"@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:
|
||||
|
|
@ -2395,13 +2379,6 @@ __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"
|
||||
|
|
@ -4207,26 +4184,6 @@ __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"
|
||||
|
|
@ -4245,13 +4202,6 @@ __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"
|
||||
|
|
@ -4454,13 +4404,6 @@ __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:."
|
||||
|
|
@ -4519,15 +4462,6 @@ __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"
|
||||
|
|
@ -4644,13 +4578,6 @@ __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"
|
||||
|
|
@ -4707,13 +4634,6 @@ __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"
|
||||
|
|
@ -4748,13 +4668,6 @@ __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"
|
||||
|
|
@ -4776,13 +4689,6 @@ __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"
|
||||
|
|
@ -4987,13 +4893,6 @@ __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"
|
||||
|
|
@ -5421,13 +5320,6 @@ __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"
|
||||
|
|
@ -5458,7 +5350,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"es-module-lexer@npm:^2.0.0, es-module-lexer@npm:^2.3.2":
|
||||
"es-module-lexer@npm:^2.0.0":
|
||||
version: 2.3.2
|
||||
resolution: "es-module-lexer@npm:2.3.2"
|
||||
checksum: 10c0/5e7389424c43478439f12f9a6aca1750f6f99afa384fc3de329f4a45f152ab156671055008adaafc74960877abf8cc338aeebf6bed8c21297b146fd6eb7a22f8
|
||||
|
|
@ -5886,13 +5778,6 @@ __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"
|
||||
|
|
@ -5946,32 +5831,6 @@ __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"
|
||||
|
|
@ -6258,13 +6117,6 @@ __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"
|
||||
|
|
@ -6344,13 +6196,6 @@ __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"
|
||||
|
|
@ -6392,13 +6237,6 @@ __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"
|
||||
|
|
@ -6531,13 +6369,6 @@ __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"
|
||||
|
|
@ -6545,13 +6376,6 @@ __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"
|
||||
|
|
@ -7025,15 +6849,6 @@ __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"
|
||||
|
|
@ -7045,17 +6860,6 @@ __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"
|
||||
|
|
@ -7220,7 +7024,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"minimatch@npm:^9.0.4, minimatch@npm:^9.0.9":
|
||||
"minimatch@npm:^9.0.4":
|
||||
version: 9.0.9
|
||||
resolution: "minimatch@npm:9.0.9"
|
||||
dependencies:
|
||||
|
|
@ -7289,13 +7093,6 @@ __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"
|
||||
|
|
@ -7319,7 +7116,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"node-fetch@npm:^3.3.0, node-fetch@npm:^3.3.2":
|
||||
"node-fetch@npm:^3.3.2":
|
||||
version: 3.3.2
|
||||
resolution: "node-fetch@npm:3.3.2"
|
||||
dependencies:
|
||||
|
|
@ -7391,7 +7188,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"obug@npm:^2.1.1, obug@npm:^2.1.4":
|
||||
"obug@npm:^2.1.1":
|
||||
version: 2.2.1
|
||||
resolution: "obug@npm:2.2.1"
|
||||
checksum: 10c0/8a7c001847d880fa6ba83c7bbee6a80fd271ee628439cd508c8647d53b217abb1759e2d5e4aaeb09a35d0a274bdb225a0a5a78eded3213d63e656153cf411b63
|
||||
|
|
@ -7454,15 +7251,6 @@ __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"
|
||||
|
|
@ -7556,13 +7344,6 @@ __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"
|
||||
|
|
@ -7577,13 +7358,6 @@ __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"
|
||||
|
|
@ -7684,13 +7458,6 @@ __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"
|
||||
|
|
@ -7891,13 +7658,6 @@ __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"
|
||||
|
|
@ -8368,13 +8128,6 @@ __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"
|
||||
|
|
@ -8455,13 +8208,6 @@ __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"
|
||||
|
|
@ -8469,13 +8215,6 @@ __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"
|
||||
|
|
@ -8597,15 +8336,6 @@ __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"
|
||||
|
|
@ -8678,13 +8408,6 @@ __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"
|
||||
|
|
@ -8692,13 +8415,6 @@ __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"
|
||||
|
|
@ -9265,23 +8981,6 @@ __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"
|
||||
|
|
@ -9519,67 +9218,6 @@ __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"
|
||||
|
|
@ -9818,29 +9456,6 @@ __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"
|
||||
|
|
@ -9881,18 +9496,6 @@ __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"
|
||||
|
|
@ -9941,13 +9544,6 @@ __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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue