feat(web): browse page with ensurePath play
This commit is contained in:
parent
113c7f2848
commit
5a291a9127
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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} />
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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.",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue