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:
parent
1142174f8f
commit
951fadd4a6
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
|
|
||||||
## 弹幕设置
|
## 弹幕设置
|
||||||
|
|
||||||
侧栏 **弹幕** 页面可以调:
|
侧栏 **设置** 页面的弹幕区块可以调:
|
||||||
|
|
||||||
- 默认开还是关
|
- 默认开还是关
|
||||||
- 透明度(怕挡画面就调淡一点)
|
- 透明度(怕挡画面就调淡一点)
|
||||||
|
|
|
||||||
|
|
@ -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/
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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"]);
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "已保存",
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue