chore: batch pending changes (danmaku filter, rootPath migration, i18n)

- web: artplayer danmaku block-filter lib and danmaku/settings page updates
- mount: replacePathPrefix dao helper for rootPath migration on re-mount
- stream/webdav: error copy, timeout handling and test coverage tweaks
- i18n/docs: danmaku guide wording updates
This commit is contained in:
noelorin 2026-09-27 22:28:23 +08:00
parent 1142174f8f
commit 951fadd4a6
24 changed files with 362 additions and 147 deletions

View File

@ -33,7 +33,7 @@ A video and a same-named danmaku file in one folder is also picked up.
## Danmaku settings ## Danmaku settings
The **Danmaku** page lets you set: The **Settings** page's Danmaku section lets you set:
- On or off by default - On or off by default
- Opacity (lower it if it blocks the picture) - Opacity (lower it if it blocks the picture)

View File

@ -35,7 +35,7 @@
## 弹幕设置 ## 弹幕设置
侧栏 **弹幕** 页面可以调: 侧栏 **设置** 页面的弹幕区块可以调:
- 默认开还是关 - 默认开还是关
- 透明度(怕挡画面就调淡一点) - 透明度(怕挡画面就调淡一点)

View File

@ -22,7 +22,7 @@ src/
pages/ pages/
[locale]/ [locale]/
index.astro | dashboard.astro | library.astro | browse.astro index.astro | dashboard.astro | library.astro | browse.astro
watch.astro | mounts.astro | danmaku.astro | settings.astro watch.astro | mounts.astro | danmaku.astro(302→settings) | settings.astro
auth/login.astro | auth/register.astro auth/login.astro | auth/register.astro
auth/forgot-password.astro | auth/reset-password.astro auth/forgot-password.astro | auth/reset-password.astro
api/trpc/[...trpc].ts | api/stream.ts | api/users.ts api/trpc/[...trpc].ts | api/stream.ts | api/users.ts
@ -30,13 +30,13 @@ src/
403.astro | 404.astro | 500.astro 403.astro | 404.astro | 500.astro
views/ # SSR 壳 + seo/nav(每页一个) views/ # SSR 壳 + seo/nav(每页一个)
home.astro | dashboard.astro | library.astro | browse.astro home.astro | dashboard.astro | library.astro | browse.astro
watch.astro | mounts.astro | danmaku.astro | auth.astro | error.astro watch.astro | mounts.astro | auth.astro | error.astro
middleware.ts # 裸 / 语言检测跳转 + locale 规范化 middleware.ts # 裸 / 语言检测跳转 + locale 规范化
layouts/AppLayout.astro layouts/AppLayout.astro
components/ components/
pages/ # 薄 SSR 主体(client:load) pages/ # 薄 SSR 主体(client:load)
HomeBody.tsx | DashboardBody.tsx | LibraryBody.tsx | BrowseBody.tsx HomeBody.tsx | DashboardBody.tsx | LibraryBody.tsx | BrowseBody.tsx
WatchBody.tsx | MountsBody.tsx | DanmakuSettingsBody.tsx WatchBody.tsx | MountsBody.tsx | DanmakuSettingsBody.tsx(settings 页弹幕区块)
AuthBody.tsx | ErrorBody.tsx AuthBody.tsx | ErrorBody.tsx
UserList.tsx | UserMenu.tsx | ThemeSwitch.tsx | AuthForm.tsx | errors/* UserList.tsx | UserMenu.tsx | ThemeSwitch.tsx | AuthForm.tsx | errors/*
lib/ lib/

View File

@ -157,7 +157,7 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
const homeBase = home(locale); const homeBase = home(locale);
return ( return (
<Card className="w-full max-w-md"> <Card className={`w-full ${mode === "change" ? "max-w-2xl" : "max-w-md"}`}>
<CardHeader> <CardHeader>
<CardTitle>{titles[mode].title}</CardTitle> <CardTitle>{titles[mode].title}</CardTitle>
<CardDescription>{titles[mode].desc}</CardDescription> <CardDescription>{titles[mode].desc}</CardDescription>

View File

@ -3,7 +3,6 @@ import { t } from "@app/i18n";
import { Button, Card, Input, toast } from "@app/ui"; import { Button, Card, Input, toast } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { go } from "~/lib/nav";
import { createTRPCReactClient, trpc } from "~/lib/trpc"; import { createTRPCReactClient, trpc } from "~/lib/trpc";
const DANMAKU_SETTINGS_KEY = "media-danmaku-settings"; const DANMAKU_SETTINGS_KEY = "media-danmaku-settings";
@ -146,17 +145,14 @@ function Inner(props: { locale: Locale }) {
const openNetworkMissing = prefsQuery.data?.openNetworkConfigured === false; const openNetworkMissing = prefsQuery.data?.openNetworkConfigured === false;
return ( return (
<div className="space-y-6"> <div className="space-y-4">
<h1 className="text-xl font-semibold">{t(locale, "danmaku.title")}</h1>
{openNetworkMissing && ( {openNetworkMissing && (
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 px-4 py-3 text-sm text-amber-800 dark:text-amber-200"> <div className="rounded-lg border border-amber-500/50 bg-amber-500/10 px-4 py-3 text-sm text-amber-800 dark:text-amber-200">
{t(locale, "danmaku.openNetworkNotConfigured")}{" "} {t(locale, "danmaku.openNetworkNotConfigured")}
<a className="underline underline-offset-2" href={`/${locale}/settings`}>
{t(locale, "auth.menu.settings")}
</a>
</div> </div>
)} )}
<Card className="max-w-md space-y-5 p-6"> <Card className="w-full space-y-5 p-6">
<h2 className="text-base font-semibold">{t(locale, "settings.danmakuSection")}</h2>
<label className="flex items-center gap-2.5 text-sm font-medium"> <label className="flex items-center gap-2.5 text-sm font-medium">
<input <input
type="checkbox" type="checkbox"
@ -264,24 +260,6 @@ function Inner(props: { locale: Locale }) {
{t(locale, "danmaku.localImportHint")} {t(locale, "danmaku.localImportHint")}
</p> </p>
</Card> </Card>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => go(`/${locale}/library`)}
>
{t(locale, "nav.library")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => go(`/${locale}/browse`)}
>
{t(locale, "nav.browse")}
</Button>
</div>
</div> </div>
); );
} }

View File

@ -46,7 +46,7 @@ function HomeInner({ locale }: HomeBodyProps) {
{ key: "nav.mounts", href: `/${locale}/mounts` }, { key: "nav.mounts", href: `/${locale}/mounts` },
{ key: "nav.browse", href: `/${locale}/browse` }, { key: "nav.browse", href: `/${locale}/browse` },
{ key: "nav.library", href: `/${locale}/library` }, { key: "nav.library", href: `/${locale}/library` },
{ key: "nav.danmaku", href: `/${locale}/danmaku` }, { key: "nav.settings", href: `/${locale}/settings` },
] as const; ] as const;
return ( return (

View File

@ -49,7 +49,7 @@ function Inner(props: { locale: Locale }) {
const label = (key: MessageKey) => t(locale, key); const label = (key: MessageKey) => t(locale, key);
return ( return (
<Card className="space-y-3 p-4"> <Card className="space-y-4 p-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="text-base font-semibold">{label("settings.adminSection")}</h2> <h2 className="text-base font-semibold">{label("settings.adminSection")}</h2>
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[11px] text-primary"> <span className="rounded bg-primary/10 px-1.5 py-0.5 text-[11px] text-primary">
@ -214,9 +214,7 @@ function Inner(props: { locale: Locale }) {
: label("settings.autoScrapeOff")} : label("settings.autoScrapeOff")}
</span> </span>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">{label("settings.autoScrapeHint")}</p>
{label("settings.autoScrapeHint")}
</p>
<label className="flex cursor-pointer items-center gap-2 text-sm"> <label className="flex cursor-pointer items-center gap-2 text-sm">
<input <input
type="checkbox" type="checkbox"

View File

@ -16,9 +16,35 @@ import {
import { go, queryParam } from "~/lib/nav"; import { go, queryParam } from "~/lib/nav";
import { createTRPCReactClient, trpc } from "~/lib/trpc"; import { createTRPCReactClient, trpc } from "~/lib/trpc";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { danmukuPassesFilter, emptyFilterOptions } from "~/lib/artplayer-danmaku-filter";
const DANMAKU_SETTINGS_KEY = "media-danmaku-settings"; const DANMAKU_SETTINGS_KEY = "media-danmaku-settings";
// ArtPlayer 与弹幕插件只在模块首次求值时向 <head> 注入 <style>;Astro ClientRouter 软导航的
// swapHeadElements 会删掉 SSR HTML 中不存在的 head 节点,而 ESM 模块有缓存不会二次执行,
// 之后再进播放页就没有任何播放器样式(图标黑填充 + 黑底,控件看似“没加载出来”)。
// 这里缓存注入文本,创建播放器前按需回填。
const INJECTED_STYLE_IDS = ["artplayer-style", "artplayer-plugin-danmuku"] as const;
const injectedStyleText = new Map<string, string>();
function ensureArtStyles(): void {
if (typeof document === "undefined") return;
for (const id of INJECTED_STYLE_IDS) {
const existing = document.getElementById(id);
if (existing?.textContent) {
injectedStyleText.set(id, existing.textContent);
continue;
}
const text = id === "artplayer-style" ? Artplayer.STYLE : injectedStyleText.get(id);
if (!text) continue;
const style = document.createElement("style");
style.id = id;
style.textContent = text;
document.head.appendChild(style);
}
}
ensureArtStyles();
type DanmakuPrefs = { enabled: boolean; opacity: number; density: number }; type DanmakuPrefs = { enabled: boolean; opacity: number; density: number };
type DanmukuPlugin = { type DanmukuPlugin = {
@ -150,6 +176,10 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
const [danmakuDensity, setDanmakuDensity] = useState(prefsRef.current.density); const [danmakuDensity, setDanmakuDensity] = useState(prefsRef.current.density);
const [ended, setEnded] = useState(false); const [ended, setEnded] = useState(false);
const [playError, setPlayError] = useState(false); const [playError, setPlayError] = useState(false);
/** 播放错误后探测到的 /api/stream 状态:404/410 = 片源失效,给处理入口而非仅重试 */
const [streamGone, setStreamGone] = useState(false);
/** 插件 option.filter 实时读取的屏蔽规则(关键词/类型),过滤在插件内完成 */
const filterRef = useRef<FilterOptions>(emptyFilterOptions());
const [matchOpen, setMatchOpen] = useState(false); const [matchOpen, setMatchOpen] = useState(false);
const [kwInput, setKwInput] = useState(""); const [kwInput, setKwInput] = useState("");
const [filterDraft, setFilterDraft] = useState<FilterOptions | null>(null); const [filterDraft, setFilterDraft] = useState<FilterOptions | null>(null);
@ -161,6 +191,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
setFilterDraft(null); setFilterDraft(null);
setMatchOpen(false); setMatchOpen(false);
setPlayError(false); setPlayError(false);
setStreamGone(false);
setEnded(false); setEnded(false);
setSourceKeyword(""); setSourceKeyword("");
setSourceQuery(""); setSourceQuery("");
@ -254,6 +285,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
useEffect(() => { useEffect(() => {
const mountEl = playerRef.current; const mountEl = playerRef.current;
if (!mountEl || !detailId) return; if (!mountEl || !detailId) return;
ensureArtStyles();
const prefs = prefsRef.current; const prefs = prefsRef.current;
const art = new Artplayer({ const art = new Artplayer({
container: mountEl, container: mountEl,
@ -269,6 +301,8 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
plugins: [ plugins: [
artplayerPluginDanmuku({ artplayerPluginDanmuku({
danmuku: [], danmuku: [],
// 屏蔽规则在插件入队时过滤(读 filterRef,规则变更由 load effect 重筛)
filter: (danmu) => danmukuPassesFilter(danmu, filterRef.current),
opacity: prefs.opacity, opacity: prefs.opacity,
fontSize: 25, fontSize: 25,
speed: 5, speed: 5,
@ -292,11 +326,24 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
}); });
// 播放失败 → 业务级错误卡(含重试);加载成功后清除 // 播放失败 → 业务级错误卡(含重试);加载成功后清除
// 同时探测真实状态码:404/410 说明片源/挂载已失效,UI 展示处理入口而非无脑重连
const onVideoError = () => { const onVideoError = () => {
setPlayError(true); setPlayError(true);
void (async () => {
try {
const res = await fetch(`/api/stream?id=${encodeURIComponent(mediaItemId)}`, {
headers: { Range: "bytes=0-0" },
});
void res.body?.cancel();
setStreamGone(res.status === 404 || res.status === 410);
} catch {
setStreamGone(false);
}
})();
}; };
const onVideoReady = () => { const onVideoReady = () => {
setPlayError(false); setPlayError(false);
setStreamGone(false);
}; };
art.on("video:error", onVideoError); art.on("video:error", onVideoError);
art.on("video:loadeddata", onVideoReady); art.on("video:loadeddata", onVideoReady);
@ -400,10 +447,13 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
}, [progress.data]); }, [progress.data]);
const xmlSource = danmakuOn ? localXml || matchXml || openDanmaku.data?.xml || "" : ""; const xmlSource = danmakuOn ? localXml || matchXml || openDanmaku.data?.xml || "" : "";
const listComments = useMemo(() => { const rawComments = useMemo(() => {
const raw = xmlSource ? parseDanmakuXml(xmlSource) : []; const raw = xmlSource ? parseDanmakuXml(xmlSource) : [];
raw.sort((a, b) => a.time - b.time); raw.sort((a, b) => a.time - b.time);
const filters: FilterOptions | null = filterDraft return raw;
}, [xmlSource]);
const activeFilters: FilterOptions | null = filterDraft
? filterDraft ? filterDraft
: prefs.data : prefs.data
? { ? {
@ -411,21 +461,31 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
blockTypes: prefs.data.blockTypes, blockTypes: prefs.data.blockTypes,
} }
: null; : null;
return filters ? filterComments(raw, filters) : raw;
}, [xmlSource, prefs.data, filterDraft]);
const items = useMemo(() => {
return toDanmukuItems(thinDanmaku(listComments, danmakuDensity));
}, [listComments, danmakuDensity]);
const danmakuCount = items.length;
// 弹幕数据变化时交给官方插件渲染 /** 侧栏列表用:过滤后视图(播放器侧过滤在插件内完成) */
const listComments = useMemo(
() => (activeFilters ? filterComments(rawComments, activeFilters) : rawComments),
[rawComments, activeFilters],
);
/** 播放器数据源:全量 + 密度抽稀,关键词/类型屏蔽交给插件 option.filter */
const items = useMemo(
() => toDanmukuItems(thinDanmaku(rawComments, danmakuDensity)),
[rawComments, danmakuDensity],
);
const danmakuCount = useMemo(
() => thinDanmaku(listComments, danmakuDensity).length,
[listComments, danmakuDensity],
);
// 弹幕数据或屏蔽规则变化 → 重载入队;filterRef 先行更新,入队时按新规则筛
useEffect(() => { useEffect(() => {
filterRef.current = activeFilters ?? emptyFilterOptions();
const art = artRef.current; const art = artRef.current;
if (!art) return; if (!art) return;
const dm = danmukuPlugin(art); const dm = danmukuPlugin(art);
if (!dm) return; if (!dm) return;
void dm.load(items); void dm.load(items);
}, [items]); }, [items, activeFilters]);
// 外部开关同步到插件显隐 // 外部开关同步到插件显隐
useEffect(() => { useEffect(() => {
@ -500,15 +560,6 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
[selectMut, mediaItemId, locale, openDanmaku], [selectMut, mediaItemId, locale, openDanmaku],
); );
const activeFilters: FilterOptions | null = filterDraft
? filterDraft
: prefs.data
? {
blockKeywords: prefs.data.blockKeywords,
blockTypes: prefs.data.blockTypes,
}
: null;
const addBlockKeyword = useCallback(() => { const addBlockKeyword = useCallback(() => {
const kw = kwInput.trim(); const kw = kwInput.trim();
if (!kw) return; if (!kw) return;
@ -561,6 +612,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
const art = artRef.current; const art = artRef.current;
if (!art) return; if (!art) return;
setPlayError(false); setPlayError(false);
setStreamGone(false);
art.switchUrl(`/api/stream?id=${encodeURIComponent(mediaItemId)}`); art.switchUrl(`/api/stream?id=${encodeURIComponent(mediaItemId)}`);
}, [mediaItemId]); }, [mediaItemId]);
@ -568,6 +620,10 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
go(`/${locale}/library`); go(`/${locale}/library`);
}, [locale]); }, [locale]);
const goMounts = useCallback(() => {
go(`/${locale}/mounts`);
}, [locale]);
if (detail.isError) { if (detail.isError) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
@ -636,8 +692,6 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
? `${t(locale, "watch.danmakuLocal")} · ${t(locale, "watch.danmakuCount", { count: danmakuCount })}` ? `${t(locale, "watch.danmakuLocal")} · ${t(locale, "watch.danmakuCount", { count: danmakuCount })}`
: matchXml : matchXml
? t(locale, "watch.danmakuOpenNetwork") ? t(locale, "watch.danmakuOpenNetwork")
: openDanmaku.data?.reason === "need-login"
? t(locale, "watch.needDdpLogin")
: openDanmaku.data?.reason === "no-app-cred" : openDanmaku.data?.reason === "no-app-cred"
? t(locale, "watch.noAppCred") ? t(locale, "watch.noAppCred")
: openDanmaku.data?.reason === "no-match" : openDanmaku.data?.reason === "no-match"
@ -685,15 +739,25 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
{playError && ( {playError && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-3 bg-black/80 p-6 text-center"> <div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-3 bg-black/80 p-6 text-center">
<p className="text-base font-medium text-white"> <p className="text-base font-medium text-white">
{t(locale, "watch.playFailed")} {streamGone
? t(locale, "watch.sourceGone")
: t(locale, "watch.playFailed")}
</p> </p>
<p className="max-w-md text-sm text-white/70"> <p className="max-w-md text-sm text-white/70">
{t(locale, "watch.playFailedHint")} {streamGone
? t(locale, "watch.sourceGoneHint")
: t(locale, "watch.playFailedHint")}
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
{streamGone ? (
<Button size="sm" onClick={goMounts}>
{t(locale, "nav.mounts")}
</Button>
) : (
<Button size="sm" onClick={retryStream}> <Button size="sm" onClick={retryStream}>
{t(locale, "error.retry")} {t(locale, "error.retry")}
</Button> </Button>
)}
<Button size="sm" variant="outline" onClick={goLibrary}> <Button size="sm" variant="outline" onClick={goLibrary}>
{t(locale, "watch.backToLibrary")} {t(locale, "watch.backToLibrary")}
</Button> </Button>
@ -714,8 +778,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
{t(locale, "watch.rematch")} {t(locale, "watch.rematch")}
</Button> </Button>
)} )}
{(openDanmaku.data?.reason === "need-login" || {openDanmaku.data?.reason === "no-app-cred" && (
openDanmaku.data?.reason === "no-app-cred") && (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"

View File

@ -28,7 +28,7 @@ export interface Props {
locale: Locale; locale: Locale;
brand?: string; brand?: string;
/** 当前页,用于侧栏高亮 */ /** 当前页,用于侧栏高亮 */
active?: "home" | "library" | "browse" | "mounts" | "danmaku"; active?: "home" | "library" | "browse" | "mounts" | "settings";
seo?: PageSeoInput; seo?: PageSeoInput;
/** 播放页隐藏模板页脚 */ /** 播放页隐藏模板页脚 */
hideFooter?: boolean; hideFooter?: boolean;
@ -64,7 +64,7 @@ const nav = [
{ id: "library", n: "01", label: t(locale, "nav.library"), href: localePath(locale, "/library") }, { id: "library", n: "01", label: t(locale, "nav.library"), href: localePath(locale, "/library") },
{ id: "browse", n: "02", label: t(locale, "nav.browse"), href: localePath(locale, "/browse") }, { id: "browse", n: "02", label: t(locale, "nav.browse"), href: localePath(locale, "/browse") },
{ id: "mounts", n: "03", label: t(locale, "nav.mounts"), href: localePath(locale, "/mounts") }, { id: "mounts", n: "03", label: t(locale, "nav.mounts"), href: localePath(locale, "/mounts") },
{ id: "danmaku", n: "04", label: t(locale, "nav.danmaku"), href: localePath(locale, "/danmaku") }, { id: "settings", n: "04", label: t(locale, "nav.settings"), href: localePath(locale, "/settings") },
] as const; ] as const;
--- ---

View File

@ -0,0 +1,27 @@
import type { FilterOptions } from "~/lib/danmaku-render";
/** artplayer-plugin-danmuku 的 Danmu 形状(只取过滤所需字段) */
export type DanmuLike = {
text: string;
mode?: number | undefined;
};
export function emptyFilterOptions(): FilterOptions {
return { blockKeywords: [], blockTypes: [] };
}
/**
* 插件入队过滤(option.filter):与 @app/danmaku 的 filterComments 同语义
* (关键词 includes + 类型屏蔽),按插件 mode(0 滚动 / 1 顶部 / 2 底部)判型。
* 屏蔽在 ArtPlayer 插件内完成,React 只喂全量数据;规则经 filterRef 实时读取。
*/
export function danmukuPassesFilter(danmu: DanmuLike, opts: FilterOptions): boolean {
const kind: "scroll" | "top" | "bottom" =
danmu.mode === 1 ? "top" : danmu.mode === 2 ? "bottom" : "scroll";
if (opts.blockTypes.includes(kind)) return false;
for (const raw of opts.blockKeywords) {
const kw = raw.trim();
if (kw && danmu.text.includes(kw)) return false;
}
return true;
}

View File

@ -13,7 +13,6 @@ const PROTECTED_BASES = [
"/browse", "/browse",
"/watch", "/watch",
"/mounts", "/mounts",
"/danmaku",
] as const; ] as const;
const LOCALE_SEGMENTS = new Set(["zh-CN", "en"]); const LOCALE_SEGMENTS = new Set(["zh-CN", "en"]);

View File

@ -1,6 +1,5 @@
--- ---
import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n"; import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n";
import Danmaku from "~/views/danmaku.astro";
const raw = Astro.params["locale"]; const raw = Astro.params["locale"];
const normalized = raw === "zh-cn" ? "zh-CN" : raw; const normalized = raw === "zh-cn" ? "zh-CN" : raw;
@ -10,10 +9,10 @@ if (!normalized || !isLocale(normalized)) {
Astro.cookies.get(COOKIE_LANG)?.value, Astro.cookies.get(COOKIE_LANG)?.value,
Astro.request.headers.get("accept-language"), Astro.request.headers.get("accept-language"),
); );
return Astro.redirect(localePath(preferred, "/danmaku")); return Astro.redirect(localePath(preferred, "/settings"));
} }
const locale: Locale = normalized; const locale: Locale = normalized;
// 弹幕设置已整合进 settings 页,旧地址保留重定向
return Astro.redirect(localePath(locale, "/settings"));
--- ---
<Danmaku locale={locale} />

View File

@ -8,7 +8,8 @@ import {
type Locale, type Locale,
} from "@app/i18n"; } from "@app/i18n";
import AppLayout from "~/layouts/AppLayout.astro"; import AppLayout from "~/layouts/AppLayout.astro";
import AuthBody from "~/components/pages/AuthBody"; import AuthForm from "~/components/AuthForm";
import { DanmakuSettingsBody } from "~/components/pages/DanmakuSettingsBody";
import { SettingsAdminBody } from "~/components/pages/SettingsAdminBody"; import { SettingsAdminBody } from "~/components/pages/SettingsAdminBody";
const raw = Astro.params["locale"]; const raw = Astro.params["locale"];
@ -23,16 +24,20 @@ if (!normalized || !isLocale(normalized)) {
} }
const locale: Locale = normalized; const locale: Locale = normalized;
const pageTitle = `${t(locale, "auth.settings.title")} · ${t(locale, "meta.brand")}`; const pageTitle = `${t(locale, "settings.title")} · ${t(locale, "meta.brand")}`;
--- ---
<AppLayout <AppLayout
title={pageTitle} title={pageTitle}
locale={locale} locale={locale}
active="settings"
seo={{ title: pageTitle, path: Astro.url.pathname, robots: ["noindex", "nofollow"] }} seo={{ title: pageTitle, path: Astro.url.pathname, robots: ["noindex", "nofollow"] }}
> >
<div class="space-y-6"> {/* 单列限宽:三张设置卡等宽对齐(账户 / 弹幕 / 开放网络) */}
<AuthBody client:load locale={locale} mode="change" /> <div class="max-w-2xl space-y-6">
<h1 class="text-xl font-semibold">{t(locale, "settings.title")}</h1>
<AuthForm locale={locale} mode="change" client:load />
<DanmakuSettingsBody locale={locale} client:load />
<SettingsAdminBody locale={locale} client:load /> <SettingsAdminBody locale={locale} client:load />
</div> </div>
</AppLayout> </AppLayout>

View File

@ -1,25 +0,0 @@
---
import { type Locale, t } from "@app/i18n";
import { DanmakuSettingsBody } from "~/components/pages/DanmakuSettingsBody";
import AppLayout from "~/layouts/AppLayout.astro";
export interface Props {
locale: Locale;
}
const { locale } = Astro.props;
---
<AppLayout
title={t(locale, "danmaku.title")}
locale={locale}
active="danmaku"
brand={t(locale, "meta.product")}
seo={{
title: `${t(locale, "danmaku.title")} · ${t(locale, "meta.brand")}`,
path: Astro.url.pathname,
robots: ["noindex", "nofollow"],
}}
>
<DanmakuSettingsBody locale={locale} client:load />
</AppLayout>

View File

@ -1,4 +1,4 @@
import { and, asc, desc, eq, inArray, isNotNull, like, or, sql } from "drizzle-orm"; import { and, asc, desc, eq, inArray, isNotNull, like, not, or, sql } from "drizzle-orm";
import { mediaItems, mounts, type MediaItem } from "@app/models"; import { mediaItems, mounts, type MediaItem } from "@app/models";
import { db } from "@app/db"; import { db } from "@app/db";
@ -174,6 +174,52 @@ export const mediaItemDao = {
) )
.orderBy(asc(mediaItems.path)); .orderBy(asc(mediaItems.path));
}, },
/**
* path 前缀批量替换(挂载 rootPath 变更时由 service 调用,path 相对 root 存储)。
* from 按目录段边界匹配(from="" 匹配全部绝对路径),替换为 to + 剩余部分。
* 迁移目标若与集合外已有行冲突(同一文件在新旧 root 期各留一条),删除已有行——
* 保留被迁移行,否则 unique (user, mount, path) 会让整批更新失败。
*/
async replacePathPrefix(
userId: string,
mountId: string,
from: string,
to: string,
): Promise<{ updated: number; removedConflicts: number }> {
const matchCond = () =>
or(eq(mediaItems.path, from), like(mediaItems.path, `${from}/%`)) ??
eq(mediaItems.path, from);
const base = [eq(mediaItems.userId, userId), eq(mediaItems.mountId, mountId)];
const rows = await db
.select({ path: mediaItems.path })
.from(mediaItems)
.where(and(...base, matchCond()));
if (rows.length === 0) return { updated: 0, removedConflicts: 0 };
const targets = new Set(rows.map((r) => to + r.path.slice(from.length)));
let removedConflicts = 0;
const targetList = [...targets];
// inArray 展开为绑定参数,分批避开变量数上限
for (let i = 0; i < targetList.length; i += 400) {
const chunk = targetList.slice(i, i + 400);
const deleted = await db
.delete(mediaItems)
.where(and(...base, not(matchCond()), inArray(mediaItems.path, chunk)))
.returning({ id: mediaItems.id });
removedConflicts += deleted.length;
}
const updatedRows = await db
.update(mediaItems)
.set({
path: sql`${to} || substr(${mediaItems.path}, ${from.length + 1})`,
updatedAt: new Date(),
})
.where(and(...base, matchCond()))
.returning({ id: mediaItems.id });
return { updated: updatedRows.length, removedConflicts };
},
async getByIdForUser(id: string, userId: string): Promise<MediaItemRow | null> { async getByIdForUser(id: string, userId: string): Promise<MediaItemRow | null> {
const [row] = await db const [row] = await db
.select() .select()

View File

@ -15,7 +15,7 @@ export const en: Record<MessageKey, string> = {
"nav.browse": "Browse", "nav.browse": "Browse",
"nav.watch": "Watch", "nav.watch": "Watch",
"nav.mounts": "Mounts", "nav.mounts": "Mounts",
"nav.danmaku": "Danmaku settings", "nav.settings": "Settings",
"home.title": "Fanmu", "home.title": "Fanmu",
"home.subtitle": "home.subtitle":
@ -203,6 +203,9 @@ export const en: Record<MessageKey, string> = {
"watch.playFailed": "Playback source failed", "watch.playFailed": "Playback source failed",
"watch.playFailedHint": "watch.playFailedHint":
"Cannot read the video stream — the mount may be removed or the source unavailable.", "Cannot read the video stream — the mount may be removed or the source unavailable.",
"watch.sourceGone": "Source unavailable",
"watch.sourceGoneHint":
"The remote file may have been deleted, or the mount credentials expired. Check the mount, rescan, then play again.",
"watch.replay": "Replay", "watch.replay": "Replay",
"watch.playbackFinished": "Episode finished", "watch.playbackFinished": "Episode finished",
"watch.danmakuDisabledHint": "watch.danmakuDisabledHint":
@ -234,7 +237,6 @@ export const en: Record<MessageKey, string> = {
"watch.episodeCount": "{count} eps", "watch.episodeCount": "{count} eps",
"watch.blockKeywordPlaceholder": "Type a block word and press Enter", "watch.blockKeywordPlaceholder": "Type a block word and press Enter",
"danmaku.title": "Danmaku settings",
"danmaku.opacity": "Opacity", "danmaku.opacity": "Opacity",
"danmaku.density": "Density", "danmaku.density": "Density",
"danmaku.enabled": "Enable danmaku", "danmaku.enabled": "Enable danmaku",
@ -251,7 +253,7 @@ export const en: Record<MessageKey, string> = {
"danmaku.blockTypeTop": "Top", "danmaku.blockTypeTop": "Top",
"danmaku.blockTypeBottom": "Bottom", "danmaku.blockTypeBottom": "Bottom",
"danmaku.openNetworkNotConfigured": "danmaku.openNetworkNotConfigured":
"Open danmaku network credentials are not configured; online danmaku may be empty. Admins can set them in Settings:", 'Open danmaku network credentials are not configured; online danmaku may be empty. Configure them in the "Open danmaku network" section below.',
"danmaku.serverPrefsHint": "danmaku.serverPrefsHint":
"Preferences are stored on this account; watch-page filters and display follow this config.", "Preferences are stored on this account; watch-page filters and display follow this config.",
@ -325,6 +327,8 @@ export const en: Record<MessageKey, string> = {
"error.internal.desc": "error.internal.desc":
"The server hit an unexpected error. Retry later and report the path if it persists.", "The server hit an unexpected error. Retry later and report the path if it persists.",
"settings.title": "Settings",
"settings.danmakuSection": "Danmaku",
"settings.adminSection": "Open danmaku network", "settings.adminSection": "Open danmaku network",
"settings.adminOnly": "Admin only", "settings.adminOnly": "Admin only",
"settings.openNetworkHint": "settings.openNetworkHint":
@ -344,7 +348,8 @@ export const en: Record<MessageKey, string> = {
"settings.obtainSteps": "settings.obtainSteps":
"Steps: sign in / register at DevCenter → create an app → wait for review → copy AppId and AppSecret from the app detail", "Steps: sign in / register at DevCenter → create an app → wait for review → copy AppId and AppSecret from the app detail",
"settings.ddpLogin": "DanDanPlay account", "settings.ddpLogin": "DanDanPlay account",
"settings.ddpLoginHint": "After signing in, the server uses your session plus app credentials to fetch online danmaku (same idea as NipaPlay).", "settings.ddpLoginHint":
"After signing in, the server uses your session plus app credentials to fetch online danmaku (same idea as NipaPlay).",
"settings.ddpUsername": "Username", "settings.ddpUsername": "Username",
"settings.ddpPassword": "Password", "settings.ddpPassword": "Password",
"settings.ddpLoginBtn": "Sign in", "settings.ddpLoginBtn": "Sign in",
@ -352,15 +357,17 @@ export const en: Record<MessageKey, string> = {
"settings.ddpLoginOk": "Signed in", "settings.ddpLoginOk": "Signed in",
"settings.ddpLoginFailed": "Sign-in failed", "settings.ddpLoginFailed": "Sign-in failed",
"settings.ddpLoggedInAs": "Signed in as {name}", "settings.ddpLoggedInAs": "Signed in as {name}",
"watch.needDdpLogin": "Sign in to DanDanPlay in Settings to load online danmaku",
"watch.gotoSettings": "Open settings", "watch.gotoSettings": "Open settings",
"watch.noAppCred": "Open danmaku network not configured", "watch.noAppCred": "Open danmaku network not configured",
"watch.clearLocalDanmaku": "Clear local danmaku", "watch.clearLocalDanmaku": "Clear local danmaku",
"watch.clearLocalOk": "Local danmaku cleared", "watch.clearLocalOk": "Local danmaku cleared",
"settings.nonAdminHint": "Admin configures the open network; any signed-in user can bind the shared DanDanPlay session here.", "settings.nonAdminHint":
"settings.sharedAccountHint": "The DanDanPlay account is a site-wide session used to fetch online danmaku.", "Admin configures the open network; any signed-in user can bind the shared DanDanPlay session here.",
"settings.sharedAccountHint":
"The DanDanPlay account is a site-wide session used to fetch online danmaku.",
"settings.autoScrape": "Auto scrape", "settings.autoScrape": "Auto scrape",
"settings.autoScrapeHint": "Automatically match Bangumi metadata on scan/import. Off by default to avoid external API calls.", "settings.autoScrapeHint":
"Automatically match Bangumi metadata on scan/import. Off by default to avoid external API calls.",
"settings.autoScrapeOn": "Auto scrape enabled", "settings.autoScrapeOn": "Auto scrape enabled",
"settings.autoScrapeOff": "Off (default)", "settings.autoScrapeOff": "Off (default)",
"settings.autoScrapeSaved": "Saved", "settings.autoScrapeSaved": "Saved",

View File

@ -16,7 +16,7 @@ export const zhCN = {
"nav.browse": "文件浏览", "nav.browse": "文件浏览",
"nav.watch": "播放", "nav.watch": "播放",
"nav.mounts": "挂载管理", "nav.mounts": "挂载管理",
"nav.danmaku": "弹幕设置", "nav.settings": "设置",
"home.title": "帆幕", "home.title": "帆幕",
"home.subtitle": "挂载 WebDAV / OpenList 片源,扫描入库,用 ArtPlayer 播放并加载在线弹幕。", "home.subtitle": "挂载 WebDAV / OpenList 片源,扫描入库,用 ArtPlayer 播放并加载在线弹幕。",
@ -199,6 +199,9 @@ export const zhCN = {
"watch.importFail": "弹幕导入失败", "watch.importFail": "弹幕导入失败",
"watch.playFailed": "片源加载失败", "watch.playFailed": "片源加载失败",
"watch.playFailedHint": "无法读取视频流,可能是挂载已删除或片源不可用。", "watch.playFailedHint": "无法读取视频流,可能是挂载已删除或片源不可用。",
"watch.sourceGone": "片源已失效",
"watch.sourceGoneHint":
"远端文件可能已被删除,或挂载凭证已失效。请到挂载管理检查后重新扫描再播放。",
"watch.replay": "重新播放", "watch.replay": "重新播放",
"watch.playbackFinished": "本集已播放完毕", "watch.playbackFinished": "本集已播放完毕",
"watch.danmakuDisabledHint": "弹幕已关闭,可在上方按钮或弹幕设置中开启", "watch.danmakuDisabledHint": "弹幕已关闭,可在上方按钮或弹幕设置中开启",
@ -228,7 +231,6 @@ export const zhCN = {
"watch.episodeCount": "{count} 集", "watch.episodeCount": "{count} 集",
"watch.blockKeywordPlaceholder": "输入屏蔽词后回车", "watch.blockKeywordPlaceholder": "输入屏蔽词后回车",
"danmaku.title": "弹幕设置",
"danmaku.opacity": "不透明度", "danmaku.opacity": "不透明度",
"danmaku.density": "密度", "danmaku.density": "密度",
"danmaku.enabled": "启用弹幕", "danmaku.enabled": "启用弹幕",
@ -245,7 +247,7 @@ export const zhCN = {
"danmaku.blockTypeTop": "顶部", "danmaku.blockTypeTop": "顶部",
"danmaku.blockTypeBottom": "底部", "danmaku.blockTypeBottom": "底部",
"danmaku.openNetworkNotConfigured": "danmaku.openNetworkNotConfigured":
"未配置开放弹幕网络凭据,在线弹幕可能为空。管理员可前往设置:", "未配置开放弹幕网络凭据,在线弹幕可能为空。可在下方「开放弹幕网络」区块配置。",
"danmaku.serverPrefsHint": "偏好保存在当前账号下,播放页过滤与显示沿用此配置。", "danmaku.serverPrefsHint": "偏好保存在当前账号下,播放页过滤与显示沿用此配置。",
"auth.login.title": "登录", "auth.login.title": "登录",
@ -315,6 +317,8 @@ export const zhCN = {
"error.internal.label": "系统异常", "error.internal.label": "系统异常",
"error.internal.title": "服务出错了", "error.internal.title": "服务出错了",
"error.internal.desc": "服务器处理请求时发生意外。请稍后重试;若持续出现请反馈路径与时间。", "error.internal.desc": "服务器处理请求时发生意外。请稍后重试;若持续出现请反馈路径与时间。",
"settings.title": "设置",
"settings.danmakuSection": "弹幕",
"settings.adminSection": "开放弹幕网络", "settings.adminSection": "开放弹幕网络",
"settings.adminOnly": "仅管理员", "settings.adminOnly": "仅管理员",
"settings.openNetworkHint": "settings.openNetworkHint":
@ -334,7 +338,8 @@ export const zhCN = {
"settings.obtainSteps": "settings.obtainSteps":
"步骤:登录/注册开发者中心 → 创建应用 → 等待审核 → 在应用详情复制 AppId 与 AppSecret", "步骤:登录/注册开发者中心 → 创建应用 → 等待审核 → 在应用详情复制 AppId 与 AppSecret",
"settings.ddpLogin": "弹弹play 账号", "settings.ddpLogin": "弹弹play 账号",
"settings.ddpLoginHint": "登录弹弹play 账号后,系统用该账号会话配合应用凭证拉取在线弹幕(与 NipaPlay 同类做法)。", "settings.ddpLoginHint":
"登录弹弹play 账号后,系统用该账号会话配合应用凭证拉取在线弹幕(与 NipaPlay 同类做法)。",
"settings.ddpUsername": "账号", "settings.ddpUsername": "账号",
"settings.ddpPassword": "密码", "settings.ddpPassword": "密码",
"settings.ddpLoginBtn": "登录", "settings.ddpLoginBtn": "登录",
@ -342,7 +347,6 @@ export const zhCN = {
"settings.ddpLoginOk": "已登录", "settings.ddpLoginOk": "已登录",
"settings.ddpLoginFailed": "登录失败", "settings.ddpLoginFailed": "登录失败",
"settings.ddpLoggedInAs": "已登录:{name}", "settings.ddpLoggedInAs": "已登录:{name}",
"watch.needDdpLogin": "获取在线弹幕需登录弹弹play 账号(设置页)",
"watch.gotoSettings": "去设置", "watch.gotoSettings": "去设置",
"watch.noAppCred": "未配置开放弹幕网络", "watch.noAppCred": "未配置开放弹幕网络",
"watch.clearLocalDanmaku": "清除本地弹幕", "watch.clearLocalDanmaku": "清除本地弹幕",
@ -350,7 +354,8 @@ export const zhCN = {
"settings.nonAdminHint": "在线弹幕需管理员配置开放网络;普通账号可在此登录弹弹play 会话。", "settings.nonAdminHint": "在线弹幕需管理员配置开放网络;普通账号可在此登录弹弹play 会话。",
"settings.sharedAccountHint": "弹弹play 账号为站点共用会话,用于拉取在线弹幕。", "settings.sharedAccountHint": "弹弹play 账号为站点共用会话,用于拉取在线弹幕。",
"settings.autoScrape": "自动刮削", "settings.autoScrape": "自动刮削",
"settings.autoScrapeHint": "扫描/入库时自动匹配 Bangumi 元数据。默认关闭,避免自动请求外部接口。", "settings.autoScrapeHint":
"扫描/入库时自动匹配 Bangumi 元数据。默认关闭,避免自动请求外部接口。",
"settings.autoScrapeOn": "开启自动刮削", "settings.autoScrapeOn": "开启自动刮削",
"settings.autoScrapeOff": "已关闭(默认)", "settings.autoScrapeOff": "已关闭(默认)",
"settings.autoScrapeSaved": "已保存", "settings.autoScrapeSaved": "已保存",

View File

@ -24,10 +24,6 @@ export const settingsRouter = t.router({
.query(() => settingsService.getAutoScrapeEnabled()), .query(() => settingsService.getAutoScrapeEnabled()),
saveAutoScrape: adminProcedure saveAutoScrape: adminProcedure
.input(settingsSchemas.saveAutoScrape) .input(settingsSchemas.saveAutoScrape)
.mutation(({ ctx, input }) => .mutation(({ ctx, input }) => settingsService.saveAutoScrape(ctx.userId, input.enabled)),
settingsService.saveAutoScrape(ctx.userId, input.enabled), dandanplayAuthStatus: protectedProcedure.query(() => settingsService.getDandanplayAuthStatus()),
),
dandanplayAuthStatus: protectedProcedure
.input(settingsSchemas.logoutDandanplay)
.query(() => settingsService.getDandanplayAuthStatus()),
}); });

View File

@ -7,6 +7,7 @@ import {
createWebdav, createWebdav,
joinWebdavPath, joinWebdavPath,
listDirectory, listDirectory,
rootPathPrefixShift,
type DirEntry, type DirEntry,
} from "./webdav-client.js"; } from "./webdav-client.js";
@ -122,8 +123,18 @@ export const mountService = {
} }
if (input.rootPath !== undefined) patch["rootPath"] = input.rootPath; if (input.rootPath !== undefined) patch["rootPath"] = input.rootPath;
if (input.enabled !== undefined) patch["enabled"] = input.enabled; if (input.enabled !== undefined) patch["enabled"] = input.enabled;
const existing = await mountDao.getByIdForUser(input.id, userId);
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "挂载不存在" });
const row = await mountDao.update(input.id, userId, patch); const row = await mountDao.update(input.id, userId, patch);
if (!row) throw new TRPCError({ code: "NOT_FOUND", message: "挂载不存在" }); if (!row) throw new TRPCError({ code: "NOT_FOUND", message: "挂载不存在" });
// path 相对 root 存储:root 变了必须同步迁移,否则流式读取全部 404;
// 无前缀关系(换了目录)不猜迁移,由重扫重建
if (input.rootPath !== undefined) {
const shift = rootPathPrefixShift(existing.rootPath, input.rootPath);
if (shift) {
await mediaItemDao.replacePathPrefix(userId, input.id, shift.from, shift.to);
}
}
return { mount: toPublic(row) }; return { mount: toPublic(row) };
}, },
@ -189,9 +200,7 @@ export const mountService = {
const base = describeWebdavError(e); const base = describeWebdavError(e);
return { return {
ok: false, ok: false,
message: usedStored message: usedStored ? `${base}(使用已保存的密码)` : base,
? `${base}(使用已保存的密码)`
: base,
}; };
} }
} catch (e) { } catch (e) {

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { resolveContentType } from "./stream.service.js"; import { mapUpstreamStreamError, resolveContentType } from "./stream.service.js";
describe("resolveContentType", () => { describe("resolveContentType", () => {
it("keeps existing mime", () => { it("keeps existing mime", () => {
@ -16,3 +16,39 @@ describe("resolveContentType", () => {
expect(resolveContentType(null, "/m/noext")).toBe("application/octet-stream"); expect(resolveContentType(null, "/m/noext")).toBe("application/octet-stream");
}); });
}); });
describe("mapUpstreamStreamError", () => {
it("maps upstream 404 (webdav .status) to 410 gone", () => {
const e = Object.assign(new Error("Invalid response: 404 Not Found"), { status: 404 });
const mapped = mapUpstreamStreamError(e);
expect(mapped.status).toBe(410);
expect(mapped.message).toContain("片源不存在");
});
it("parses status from message when .status is absent", () => {
const mapped = mapUpstreamStreamError(new Error("Invalid response: 404 Not Found"));
expect(mapped.status).toBe(410);
});
it("maps upstream 401/403 to 410 credentials gone", () => {
const e401 = Object.assign(new Error("Invalid response: 401 Unauthorized"), {
status: 401,
});
const e403 = Object.assign(new Error("Invalid response: 403 Forbidden"), { status: 403 });
expect(mapUpstreamStreamError(e401).status).toBe(410);
expect(mapUpstreamStreamError(e401).message).toContain("挂载凭证");
expect(mapUpstreamStreamError(e403).status).toBe(410);
});
it("keeps other failures as 502 with truncated message", () => {
const mapped = mapUpstreamStreamError(new Error("connect ECONNREFUSED 10.0.0.1:5007"));
expect(mapped.status).toBe(502);
expect(mapped.message.startsWith("片源读取失败:")).toBe(true);
const long = mapUpstreamStreamError(new Error("x".repeat(300)));
expect(long.status).toBe(502);
expect(long.message.length).toBeLessThanOrEqual("片源读取失败:".length + 120);
});
it("maps upstream 500 to 502", () => {
const e = Object.assign(new Error("Invalid response: 500 Internal Server Error"), {
status: 500,
});
expect(mapUpstreamStreamError(e).status).toBe(502);
});
});

View File

@ -18,6 +18,27 @@ export class StreamSourceError extends Error {
} }
} }
/**
* 上游 WebDAV 错误 → 业务语义:
* webdav 库的错误对象带 `.status`(message 形如 "Invalid response: 404 Not Found")。
* 404 = 远端文件已删除/移动,401/403 = 挂载凭证失效,均属片源失效(410);其余保持 502。
*/
export function mapUpstreamStreamError(e: unknown): StreamSourceError {
const msg = e instanceof Error ? e.message : String(e);
const errStatus = (e as { status?: unknown } | null)?.status;
const code =
typeof errStatus === "number"
? errStatus
: Number(/Invalid response: (\d{3})/.exec(msg)?.[1] ?? Number.NaN);
if (code === 404) {
return new StreamSourceError("片源不存在,远端文件可能已被删除或移动", 410);
}
if (code === 401 || code === 403) {
return new StreamSourceError("挂载凭证已失效,请在挂载管理中更新", 410);
}
return new StreamSourceError(`片源读取失败:${msg.slice(0, 120)}`, 502);
}
const MIME_BY_EXT: Record<string, string> = { const MIME_BY_EXT: Record<string, string> = {
mp4: "video/mp4", mp4: "video/mp4",
m4v: "video/mp4", m4v: "video/mp4",
@ -120,8 +141,7 @@ export async function openRemoteFileStream(
headers: remoteHeaders as never, headers: remoteHeaders as never,
}); });
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); throw mapUpstreamStreamError(e);
throw new StreamSourceError(`片源读取失败:${msg.slice(0, 120)}`, 502);
} }
// @buttercup/fetch 返回 Node PassThrough(ReadableLike,带 pause/resume) // @buttercup/fetch 返回 Node PassThrough(ReadableLike,带 pause/resume)
const src = (remote as { body?: unknown }).body as { const src = (remote as { body?: unknown }).body as {

View File

@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { assertSafeWebdavUrl, isVideoFilename, joinWebdavPath } from "./webdav-client.js"; import {
assertSafeWebdavUrl,
isVideoFilename,
joinWebdavPath,
rootPathPrefixShift,
} from "./webdav-client.js";
describe("webdav helpers", () => { describe("webdav helpers", () => {
it("joinWebdavPath", () => { it("joinWebdavPath", () => {
@ -10,6 +15,22 @@ describe("webdav helpers", () => {
expect(isVideoFilename("E01.mkv")).toBe(true); expect(isVideoFilename("E01.mkv")).toBe(true);
expect(isVideoFilename("readme.txt")).toBe(false); expect(isVideoFilename("readme.txt")).toBe(false);
}); });
it("rootPathPrefixShift", () => {
// root 变深:只有新 root 内的条目去前缀
expect(rootPathPrefixShift("/disk/QB", "/disk/QB/sub")).toEqual({
from: "/sub",
to: "",
});
expect(rootPathPrefixShift("/", "/disk")).toEqual({ from: "/disk", to: "" });
// root 变浅:全部条目补前缀
expect(rootPathPrefixShift("/disk/QB", "/disk")).toEqual({ from: "", to: "/QB" });
expect(rootPathPrefixShift("/disk", "/")).toEqual({ from: "", to: "/disk" });
// 相同 / 无前缀关系
expect(rootPathPrefixShift("/a", "/a/")).toBeNull();
expect(rootPathPrefixShift("/a/b", "/c")).toBeNull();
// 段边界:/ab 不是 /a 的变深
expect(rootPathPrefixShift("/a", "/ab")).toBeNull();
});
}); });
describe("assertSafeWebdavUrl", () => { describe("assertSafeWebdavUrl", () => {

View File

@ -42,6 +42,38 @@ export function joinWebdavPath(root: string, rel: string): string {
return `${r}/${p}`; return `${r}/${p}`;
} }
/** 斜杠规范化:去尾斜杠、收敛为 `/a/b` 或 `/` */
function normalizeRootPath(p: string): string {
const parts = (p || "/").trim().replace(/\\/g, "/").split("/").filter(Boolean);
return parts.length ? `/${parts.join("/")}` : "/";
}
/**
* 挂载 rootPath 变更时,把条目 path(相对旧 root 存储)迁移到相对新 root 的形态:
* 返回 { from, to } 表示「path 前缀 from → to」的批量替换(from="" 匹配全部绝对路径)。
* - root 变深(/a → /a/b):只有以 /b 开头的条目仍在库内,去前缀;其余出界不动
* - root 变浅(/a/b → /a):全部条目补上 /b 前缀
* - root 无前缀关系(换目录):返回 null,调用方应重扫而非猜迁移
*/
export function rootPathPrefixShift(
oldRoot: string,
newRoot: string,
): { from: string; to: string } | null {
const o = normalizeRootPath(oldRoot);
const n = normalizeRootPath(newRoot);
if (o === n) return null;
if (n.startsWith(o === "/" ? "/" : `${o}/`)) {
// 变深:delta 为新 root 相对旧 root 多出的段(o="/" 时 delta=n)
const delta = o === "/" ? n : n.slice(o.length);
return { from: delta, to: "" };
}
if (o.startsWith(n === "/" ? "/" : `${n}/`)) {
const delta = n === "/" ? o : o.slice(n.length);
return { from: "", to: delta };
}
return null;
}
export function createWebdav(opts: { export function createWebdav(opts: {
baseUrl: string; baseUrl: string;
username?: string | undefined; username?: string | undefined;

View File

@ -259,7 +259,6 @@ export type DanmakuFetchReason =
| "local-xml" | "local-xml"
| "cache" | "cache"
| "no-app-cred" | "no-app-cred"
| "need-login"
| "no-match" | "no-match"
| "fetch-failed" | "fetch-failed"
| "not-found" | "not-found"