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.title": "File browser",
|
||||||
"browse.play": "Play",
|
"browse.play": "Play",
|
||||||
"browse.addLibrary": "Scan into library",
|
"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.title": "Mounts",
|
||||||
"mounts.add": "Add WebDAV",
|
"mounts.add": "Add WebDAV",
|
||||||
|
|
@ -125,3 +134,4 @@ 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.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,15 @@ export const zhCN = {
|
||||||
"browse.title": "文件浏览",
|
"browse.title": "文件浏览",
|
||||||
"browse.play": "播放",
|
"browse.play": "播放",
|
||||||
"browse.addLibrary": "扫描入库",
|
"browse.addLibrary": "扫描入库",
|
||||||
|
"browse.selectMount": "选择挂载…",
|
||||||
|
"browse.mountsEmpty": "暂无挂载,请先添加",
|
||||||
|
"browse.root": "根目录",
|
||||||
|
"browse.up": "上一级",
|
||||||
|
"browse.empty": "空目录或未选择挂载",
|
||||||
|
"browse.loading": "加载中…",
|
||||||
|
"browse.loadError": "目录读取失败",
|
||||||
|
"browse.playError": "入库失败,请重试",
|
||||||
|
"browse.scanDone": "扫描完成:{count} 个文件",
|
||||||
|
|
||||||
"mounts.title": "挂载管理",
|
"mounts.title": "挂载管理",
|
||||||
"mounts.add": "添加 WebDAV",
|
"mounts.add": "添加 WebDAV",
|
||||||
|
|
@ -127,3 +136,4 @@ export const zhCN = {
|
||||||
|
|
||||||
export type MessageKey = keyof typeof zhCN;
|
export type MessageKey = keyof typeof zhCN;
|
||||||
export type Messages = Record<MessageKey, string>;
|
export type Messages = Record<MessageKey, string>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,11 @@ export const mediaRouter = t.router({
|
||||||
libraryScan: protectedProcedure
|
libraryScan: protectedProcedure
|
||||||
.input(librarySchemas.scan)
|
.input(librarySchemas.scan)
|
||||||
.mutation(({ ctx, input }) => libraryService.scan(ctx.userId, input.mountId)),
|
.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
|
scrapeSearch: protectedProcedure
|
||||||
.input(scrapeSchemas.search)
|
.input(scrapeSchemas.search)
|
||||||
|
|
@ -83,3 +88,4 @@ export const mediaRouter = t.router({
|
||||||
.input(danmakuSchemas.settings)
|
.input(danmakuSchemas.settings)
|
||||||
.mutation(({ input }) => input),
|
.mutation(({ input }) => input),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,4 +89,39 @@ export const libraryService = {
|
||||||
}
|
}
|
||||||
return { scanned: MAX_SCAN_FILES - budget.left, message: "扫描完成" };
|
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 }),
|
scan: z.object({ mountId: id }),
|
||||||
get: z.object({ id }),
|
get: z.object({ id }),
|
||||||
|
ensurePath: z.object({
|
||||||
|
mountId: id,
|
||||||
|
path: z.string().min(1).max(2000),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const playbackSchemas = {
|
export const playbackSchemas = {
|
||||||
|
|
@ -85,6 +89,7 @@ export const danmakuSchemas = {
|
||||||
export type MountCreateInput = z.infer<typeof mountSchemas.create>;
|
export type MountCreateInput = z.infer<typeof mountSchemas.create>;
|
||||||
export type MountListDirInput = z.infer<typeof mountSchemas.listDir>;
|
export type MountListDirInput = z.infer<typeof mountSchemas.listDir>;
|
||||||
export type LibraryListInput = z.infer<typeof librarySchemas.list>;
|
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 PlaybackReportInput = z.infer<typeof playbackSchemas.report>;
|
||||||
export type ScrapeSearchInput = z.infer<typeof scrapeSchemas.search>;
|
export type ScrapeSearchInput = z.infer<typeof scrapeSchemas.search>;
|
||||||
export type DanmakuFetchOutput = {
|
export type DanmakuFetchOutput = {
|
||||||
|
|
@ -94,3 +99,4 @@ export type DanmakuFetchOutput = {
|
||||||
xml: string;
|
xml: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue