feat(web): browse page with ensurePath play

This commit is contained in:
noelorin 2026-09-25 04:35:58 +08:00
parent 113c7f2848
commit 5a291a9127
8 changed files with 326 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@ -44,6 +44,15 @@ export const en: Record<MessageKey, string> = {
"browse.title": "File browser",
"browse.play": "Play",
"browse.addLibrary": "Scan into library",
"browse.selectMount": "Select mount…",
"browse.mountsEmpty": "No mounts yet",
"browse.root": "Root",
"browse.up": "Up",
"browse.empty": "Empty directory or no mount selected",
"browse.loading": "Loading…",
"browse.loadError": "Failed to read directory",
"browse.playError": "Failed to add to library",
"browse.scanDone": "Scan complete: {count} files",
"mounts.title": "Mounts",
"mounts.add": "Add WebDAV",
@ -125,3 +134,4 @@ export const en: Record<MessageKey, string> = {
"error.internal.desc":
"The server hit an unexpected error. Retry later and report the path if it persists.",
};

View File

@ -45,6 +45,15 @@ export const zhCN = {
"browse.title": "文件浏览",
"browse.play": "播放",
"browse.addLibrary": "扫描入库",
"browse.selectMount": "选择挂载…",
"browse.mountsEmpty": "暂无挂载,请先添加",
"browse.root": "根目录",
"browse.up": "上一级",
"browse.empty": "空目录或未选择挂载",
"browse.loading": "加载中…",
"browse.loadError": "目录读取失败",
"browse.playError": "入库失败,请重试",
"browse.scanDone": "扫描完成:{count} 个文件",
"mounts.title": "挂载管理",
"mounts.add": "添加 WebDAV",
@ -127,3 +136,4 @@ export const zhCN = {
export type MessageKey = keyof typeof zhCN;
export type Messages = Record<MessageKey, string>;

View File

@ -42,6 +42,11 @@ export const mediaRouter = t.router({
libraryScan: protectedProcedure
.input(librarySchemas.scan)
.mutation(({ ctx, input }) => libraryService.scan(ctx.userId, input.mountId)),
libraryEnsurePath: protectedProcedure
.input(librarySchemas.ensurePath)
.mutation(({ ctx, input }) =>
libraryService.ensurePath(ctx.userId, input.mountId, input.path),
),
scrapeSearch: protectedProcedure
.input(scrapeSchemas.search)
@ -83,3 +88,4 @@ export const mediaRouter = t.router({
.input(danmakuSchemas.settings)
.mutation(({ input }) => input),
});

View File

@ -89,4 +89,39 @@ export const libraryService = {
}
return { scanned: MAX_SCAN_FILES - budget.left, message: "扫描完成" };
},
/** 单文件入库(Browse 点播放前调用);已入库则刷新元数据后返回。 */
async ensurePath(userId: string, mountId: string, path: string): Promise<MediaItemRow> {
const mount = await mountDao.getByIdForUser(mountId, userId);
if (!mount) throw new TRPCError({ code: "NOT_FOUND", message: "挂载不存在" });
if (!mount.enabled) throw new TRPCError({ code: "BAD_REQUEST", message: "挂载已禁用" });
const client = createWebdav({
baseUrl: mount.baseUrl,
username: mount.username ?? undefined,
password: mount.secretEnc ? decryptSecret(mount.secretEnc) : undefined,
});
const abs = joinWebdavPath(mount.rootPath, path);
let size = 0;
try {
const stat = (await client.stat(abs)) as { size?: number };
size = Number(stat.size ?? 0);
} catch {
throw new TRPCError({ code: "NOT_FOUND", message: "文件不存在" });
}
const basename = path.split("/").filter(Boolean).pop() ?? path;
if (!isVideoFilename(basename)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "仅支持视频文件" });
}
const item = await mediaItemDao.upsertFromScan({
userId,
mountId,
path,
rawName: basename,
title: basename.replace(/\.[a-z0-9]+$/i, ""),
size,
});
await scrapeService.autoScrape(userId, item.id);
return item;
},
};

View File

@ -44,6 +44,10 @@ export const librarySchemas = {
}),
scan: z.object({ mountId: id }),
get: z.object({ id }),
ensurePath: z.object({
mountId: id,
path: z.string().min(1).max(2000),
}),
};
export const playbackSchemas = {
@ -85,6 +89,7 @@ export const danmakuSchemas = {
export type MountCreateInput = z.infer<typeof mountSchemas.create>;
export type MountListDirInput = z.infer<typeof mountSchemas.listDir>;
export type LibraryListInput = z.infer<typeof librarySchemas.list>;
export type LibraryEnsurePathInput = z.infer<typeof librarySchemas.ensurePath>;
export type PlaybackReportInput = z.infer<typeof playbackSchemas.report>;
export type ScrapeSearchInput = z.infer<typeof scrapeSchemas.search>;
export type DanmakuFetchOutput = {
@ -94,3 +99,4 @@ export type DanmakuFetchOutput = {
xml: string;
message?: string;
};