diff --git a/app/web/src/components/pages/BrowseBody.tsx b/app/web/src/components/pages/BrowseBody.tsx new file mode 100644 index 0000000..1733c3e --- /dev/null +++ b/app/web/src/components/pages/BrowseBody.tsx @@ -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([]); + + 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 ( +
+
+

{t(locale, "browse.title")}

+ + {mountId && ( + + )} +
+ {scan.data && ( +

+ {t(locale, "browse.scanDone", { count: scan.data.scanned })} +

+ )} + {scan.isError && ( +

{scan.error.message}

+ )} +
+ + {crumbs.map((c, i) => ( + + {" / "} + + + ))} + {crumbs.length > 0 && ( + + )} +
+ {mountId && dir.isPending && ( +

{t(locale, "browse.loading")}

+ )} + {mountId && dir.isError && ( +

{t(locale, "browse.loadError")}

+ )} + {ensure.isError && ( +

{t(locale, "browse.playError")}

+ )} + +
+ ); +} + +const client = createTRPCReactClient(); +const qc = new QueryClient(); + +export function BrowseBody(props: { locale: Locale }) { + return ( + + + + + + ); +} + diff --git a/app/web/src/pages/[locale]/browse.astro b/app/web/src/pages/[locale]/browse.astro new file mode 100644 index 0000000..98b0eba --- /dev/null +++ b/app/web/src/pages/[locale]/browse.astro @@ -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; +--- + + + diff --git a/app/web/src/views/browse.astro b/app/web/src/views/browse.astro new file mode 100644 index 0000000..b67b73f --- /dev/null +++ b/app/web/src/views/browse.astro @@ -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 = { + "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"), +}; +--- + + + + + + + + diff --git a/packages/i18n/src/messages/en.ts b/packages/i18n/src/messages/en.ts index deae3f1..e254508 100644 --- a/packages/i18n/src/messages/en.ts +++ b/packages/i18n/src/messages/en.ts @@ -44,6 +44,15 @@ export const en: Record = { "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 = { "error.internal.desc": "The server hit an unexpected error. Retry later and report the path if it persists.", }; + diff --git a/packages/i18n/src/messages/zh-CN.ts b/packages/i18n/src/messages/zh-CN.ts index 380d745..700f619 100644 --- a/packages/i18n/src/messages/zh-CN.ts +++ b/packages/i18n/src/messages/zh-CN.ts @@ -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; + diff --git a/packages/trpc/src/router/media.router.ts b/packages/trpc/src/router/media.router.ts index d201eb6..16cff3f 100644 --- a/packages/trpc/src/router/media.router.ts +++ b/packages/trpc/src/router/media.router.ts @@ -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), }); + diff --git a/packages/trpc/src/services/library.service.ts b/packages/trpc/src/services/library.service.ts index da4c1c3..94743cc 100644 --- a/packages/trpc/src/services/library.service.ts +++ b/packages/trpc/src/services/library.service.ts @@ -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 { + 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; + }, }; + diff --git a/packages/types/src/media-schemas.ts b/packages/types/src/media-schemas.ts index fd6c664..63964de 100644 --- a/packages/types/src/media-schemas.ts +++ b/packages/types/src/media-schemas.ts @@ -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; export type MountListDirInput = z.infer; export type LibraryListInput = z.infer; +export type LibraryEnsurePathInput = z.infer; export type PlaybackReportInput = z.infer; export type ScrapeSearchInput = z.infer; export type DanmakuFetchOutput = { @@ -94,3 +99,4 @@ export type DanmakuFetchOutput = { xml: string; message?: string; }; +