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
The **Danmaku** page lets you set:
The **Settings** page's Danmaku section lets you set:
- On or off by default
- Opacity (lower it if it blocks the picture)

View File

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

View File

@ -22,7 +22,7 @@ src/
pages/
[locale]/
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/forgot-password.astro | auth/reset-password.astro
api/trpc/[...trpc].ts | api/stream.ts | api/users.ts
@ -30,13 +30,13 @@ src/
403.astro | 404.astro | 500.astro
views/ # SSR 壳 + seo/nav(每页一个)
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 规范化
layouts/AppLayout.astro
components/
pages/ # 薄 SSR 主体(client:load)
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
UserList.tsx | UserMenu.tsx | ThemeSwitch.tsx | AuthForm.tsx | errors/*
lib/

View File

@ -157,7 +157,7 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
const homeBase = home(locale);
return (
<Card className="w-full max-w-md">
<Card className={`w-full ${mode === "change" ? "max-w-2xl" : "max-w-md"}`}>
<CardHeader>
<CardTitle>{titles[mode].title}</CardTitle>
<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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import { go } from "~/lib/nav";
import { createTRPCReactClient, trpc } from "~/lib/trpc";
const DANMAKU_SETTINGS_KEY = "media-danmaku-settings";
@ -146,17 +145,14 @@ function Inner(props: { locale: Locale }) {
const openNetworkMissing = prefsQuery.data?.openNetworkConfigured === false;
return (
<div className="space-y-6">
<h1 className="text-xl font-semibold">{t(locale, "danmaku.title")}</h1>
<div className="space-y-4">
{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">
{t(locale, "danmaku.openNetworkNotConfigured")}{" "}
<a className="underline underline-offset-2" href={`/${locale}/settings`}>
{t(locale, "auth.menu.settings")}
</a>
{t(locale, "danmaku.openNetworkNotConfigured")}
</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">
<input
type="checkbox"
@ -264,24 +260,6 @@ function Inner(props: { locale: Locale }) {
{t(locale, "danmaku.localImportHint")}
</p>
</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>
);
}

View File

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

View File

@ -49,7 +49,7 @@ function Inner(props: { locale: Locale }) {
const label = (key: MessageKey) => t(locale, key);
return (
<Card className="space-y-3 p-4">
<Card className="space-y-4 p-6">
<div className="flex items-center gap-2">
<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">
@ -214,9 +214,7 @@ function Inner(props: { locale: Locale }) {
: label("settings.autoScrapeOff")}
</span>
</div>
<p className="text-xs text-muted-foreground">
{label("settings.autoScrapeHint")}
</p>
<p className="text-xs text-muted-foreground">{label("settings.autoScrapeHint")}</p>
<label className="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"

View File

@ -16,9 +16,35 @@ import {
import { go, queryParam } from "~/lib/nav";
import { createTRPCReactClient, trpc } from "~/lib/trpc";
import { cn } from "~/lib/utils";
import { danmukuPassesFilter, emptyFilterOptions } from "~/lib/artplayer-danmaku-filter";
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 DanmukuPlugin = {
@ -150,6 +176,10 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
const [danmakuDensity, setDanmakuDensity] = useState(prefsRef.current.density);
const [ended, setEnded] = 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 [kwInput, setKwInput] = useState("");
const [filterDraft, setFilterDraft] = useState<FilterOptions | null>(null);
@ -161,6 +191,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
setFilterDraft(null);
setMatchOpen(false);
setPlayError(false);
setStreamGone(false);
setEnded(false);
setSourceKeyword("");
setSourceQuery("");
@ -254,6 +285,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
useEffect(() => {
const mountEl = playerRef.current;
if (!mountEl || !detailId) return;
ensureArtStyles();
const prefs = prefsRef.current;
const art = new Artplayer({
container: mountEl,
@ -269,6 +301,8 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
plugins: [
artplayerPluginDanmuku({
danmuku: [],
// 屏蔽规则在插件入队时过滤(读 filterRef,规则变更由 load effect 重筛)
filter: (danmu) => danmukuPassesFilter(danmu, filterRef.current),
opacity: prefs.opacity,
fontSize: 25,
speed: 5,
@ -292,11 +326,24 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
});
// 播放失败 → 业务级错误卡(含重试);加载成功后清除
// 同时探测真实状态码:404/410 说明片源/挂载已失效,UI 展示处理入口而非无脑重连
const onVideoError = () => {
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 = () => {
setPlayError(false);
setStreamGone(false);
};
art.on("video:error", onVideoError);
art.on("video:loadeddata", onVideoReady);
@ -400,32 +447,45 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
}, [progress.data]);
const xmlSource = danmakuOn ? localXml || matchXml || openDanmaku.data?.xml || "" : "";
const listComments = useMemo(() => {
const rawComments = useMemo(() => {
const raw = xmlSource ? parseDanmakuXml(xmlSource) : [];
raw.sort((a, b) => a.time - b.time);
const filters: FilterOptions | null = filterDraft
? filterDraft
: prefs.data
? {
blockKeywords: prefs.data.blockKeywords,
blockTypes: prefs.data.blockTypes,
}
: 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;
return raw;
}, [xmlSource]);
// 弹幕数据变化时交给官方插件渲染
const activeFilters: FilterOptions | null = filterDraft
? filterDraft
: prefs.data
? {
blockKeywords: prefs.data.blockKeywords,
blockTypes: prefs.data.blockTypes,
}
: null;
/** 侧栏列表用:过滤后视图(播放器侧过滤在插件内完成) */
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(() => {
filterRef.current = activeFilters ?? emptyFilterOptions();
const art = artRef.current;
if (!art) return;
const dm = danmukuPlugin(art);
if (!dm) return;
void dm.load(items);
}, [items]);
}, [items, activeFilters]);
// 外部开关同步到插件显隐
useEffect(() => {
@ -500,15 +560,6 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
[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 kw = kwInput.trim();
if (!kw) return;
@ -561,6 +612,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
const art = artRef.current;
if (!art) return;
setPlayError(false);
setStreamGone(false);
art.switchUrl(`/api/stream?id=${encodeURIComponent(mediaItemId)}`);
}, [mediaItemId]);
@ -568,6 +620,10 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
go(`/${locale}/library`);
}, [locale]);
const goMounts = useCallback(() => {
go(`/${locale}/mounts`);
}, [locale]);
if (detail.isError) {
return (
<div className="space-y-3">
@ -636,19 +692,17 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
? `${t(locale, "watch.danmakuLocal")} · ${t(locale, "watch.danmakuCount", { count: danmakuCount })}`
: matchXml
? t(locale, "watch.danmakuOpenNetwork")
: openDanmaku.data?.reason === "need-login"
? t(locale, "watch.needDdpLogin")
: openDanmaku.data?.reason === "no-app-cred"
? t(locale, "watch.noAppCred")
: openDanmaku.data?.reason === "no-match"
: openDanmaku.data?.reason === "no-app-cred"
? t(locale, "watch.noAppCred")
: openDanmaku.data?.reason === "no-match"
? t(locale, "watch.noDanmakuImport")
: openDanmaku.data?.source === "none"
? t(locale, "watch.noDanmakuImport")
: 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");
: 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">
@ -685,15 +739,25 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
{playError && (
<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">
{t(locale, "watch.playFailed")}
{streamGone
? t(locale, "watch.sourceGone")
: t(locale, "watch.playFailed")}
</p>
<p className="max-w-md text-sm text-white/70">
{t(locale, "watch.playFailedHint")}
{streamGone
? t(locale, "watch.sourceGoneHint")
: t(locale, "watch.playFailedHint")}
</p>
<div className="flex gap-2">
<Button size="sm" onClick={retryStream}>
{t(locale, "error.retry")}
</Button>
{streamGone ? (
<Button size="sm" onClick={goMounts}>
{t(locale, "nav.mounts")}
</Button>
) : (
<Button size="sm" onClick={retryStream}>
{t(locale, "error.retry")}
</Button>
)}
<Button size="sm" variant="outline" onClick={goLibrary}>
{t(locale, "watch.backToLibrary")}
</Button>
@ -714,8 +778,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
{t(locale, "watch.rematch")}
</Button>
)}
{(openDanmaku.data?.reason === "need-login" ||
openDanmaku.data?.reason === "no-app-cred") && (
{openDanmaku.data?.reason === "no-app-cred" && (
<Button
size="sm"
variant="outline"

View File

@ -28,7 +28,7 @@ export interface Props {
locale: Locale;
brand?: string;
/** 当前页,用于侧栏高亮 */
active?: "home" | "library" | "browse" | "mounts" | "danmaku";
active?: "home" | "library" | "browse" | "mounts" | "settings";
seo?: PageSeoInput;
/** 播放页隐藏模板页脚 */
hideFooter?: boolean;
@ -64,7 +64,7 @@ const nav = [
{ 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: "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;
---

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

View File

@ -8,7 +8,8 @@ import {
type Locale,
} from "@app/i18n";
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";
const raw = Astro.params["locale"];
@ -23,16 +24,20 @@ if (!normalized || !isLocale(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
title={pageTitle}
locale={locale}
active="settings"
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 />
</div>
</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 { db } from "@app/db";
@ -174,6 +174,52 @@ export const mediaItemDao = {
)
.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> {
const [row] = await db
.select()

View File

@ -15,7 +15,7 @@ export const en: Record<MessageKey, string> = {
"nav.browse": "Browse",
"nav.watch": "Watch",
"nav.mounts": "Mounts",
"nav.danmaku": "Danmaku settings",
"nav.settings": "Settings",
"home.title": "Fanmu",
"home.subtitle":
@ -203,6 +203,9 @@ export const en: Record<MessageKey, string> = {
"watch.playFailed": "Playback source failed",
"watch.playFailedHint":
"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.playbackFinished": "Episode finished",
"watch.danmakuDisabledHint":
@ -234,7 +237,6 @@ export const en: Record<MessageKey, string> = {
"watch.episodeCount": "{count} eps",
"watch.blockKeywordPlaceholder": "Type a block word and press Enter",
"danmaku.title": "Danmaku settings",
"danmaku.opacity": "Opacity",
"danmaku.density": "Density",
"danmaku.enabled": "Enable danmaku",
@ -251,7 +253,7 @@ export const en: Record<MessageKey, string> = {
"danmaku.blockTypeTop": "Top",
"danmaku.blockTypeBottom": "Bottom",
"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":
"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":
"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.adminOnly": "Admin only",
"settings.openNetworkHint":
@ -344,7 +348,8 @@ export const en: Record<MessageKey, string> = {
"settings.obtainSteps":
"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.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.ddpPassword": "Password",
"settings.ddpLoginBtn": "Sign in",
@ -352,15 +357,17 @@ export const en: Record<MessageKey, string> = {
"settings.ddpLoginOk": "Signed in",
"settings.ddpLoginFailed": "Sign-in failed",
"settings.ddpLoggedInAs": "Signed in as {name}",
"watch.needDdpLogin": "Sign in to DanDanPlay in Settings to load online danmaku",
"watch.gotoSettings": "Open settings",
"watch.noAppCred": "Open danmaku network not configured",
"watch.clearLocalDanmaku": "Clear local danmaku",
"watch.clearLocalOk": "Local danmaku cleared",
"settings.nonAdminHint": "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.nonAdminHint":
"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.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.autoScrapeOff": "Off (default)",
"settings.autoScrapeSaved": "Saved",

View File

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

View File

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

View File

@ -7,6 +7,7 @@ import {
createWebdav,
joinWebdavPath,
listDirectory,
rootPathPrefixShift,
type DirEntry,
} from "./webdav-client.js";
@ -122,8 +123,18 @@ export const mountService = {
}
if (input.rootPath !== undefined) patch["rootPath"] = input.rootPath;
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);
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) };
},
@ -189,9 +200,7 @@ export const mountService = {
const base = describeWebdavError(e);
return {
ok: false,
message: usedStored
? `${base}(使用已保存的密码)`
: base,
message: usedStored ? `${base}(使用已保存的密码)` : base,
};
}
} catch (e) {

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveContentType } from "./stream.service.js";
import { mapUpstreamStreamError, resolveContentType } from "./stream.service.js";
describe("resolveContentType", () => {
it("keeps existing mime", () => {
@ -16,3 +16,39 @@ describe("resolveContentType", () => {
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> = {
mp4: "video/mp4",
m4v: "video/mp4",
@ -120,8 +141,7 @@ export async function openRemoteFileStream(
headers: remoteHeaders as never,
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
throw new StreamSourceError(`片源读取失败:${msg.slice(0, 120)}`, 502);
throw mapUpstreamStreamError(e);
}
// @buttercup/fetch 返回 Node PassThrough(ReadableLike,带 pause/resume)
const src = (remote as { body?: unknown }).body as {

View File

@ -1,5 +1,10 @@
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", () => {
it("joinWebdavPath", () => {
@ -10,6 +15,22 @@ describe("webdav helpers", () => {
expect(isVideoFilename("E01.mkv")).toBe(true);
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", () => {

View File

@ -42,6 +42,38 @@ export function joinWebdavPath(root: string, rel: string): string {
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: {
baseUrl: string;
username?: string | undefined;

View File

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