diff --git a/.env.example b/.env.example index adf45b5..d163c97 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ # TorBox API Key,仅服务端使用,不会下发到浏览器 # 不配置时以演示模式启动:资源库显示内置模拟数据(src/server/mock-library.ts) TORBOX_API_KEY= +# Jackett 地址,如 http://192.168.1.10:9117 +JACKETT_URL= +# Jackett API Key +JACKETT_API_KEY= # 自定义访问密钥:网页登录、播放链接、播放列表都用它鉴权 # 演示模式下可不配置,此时访问密钥为 mock;配置了 TORBOX_API_KEY 时必须配置,否则拒绝启动 ACCESS_TOKEN= diff --git a/docs/design-system.md b/docs/design-system.md index 7fa0992..f34ca1c 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -76,6 +76,7 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx - **例外三·分组标题吸顶**:资源库的分组标题行用原生 `sticky`(全站唯一),由浏览器合成,滚动时不跑 JS。虚拟列表把每个分组的可见行包进一个与整组等高的容器(未渲染部分用占位块撑出),标题以它为边界,到分组末尾时被下一组自然推出;`rangeExtractor` 保证当前分组的标题始终渲染。吸附时的底边线用 `stuck-divider`(scroll-state 容器查询);容器查询改不了容器自身,吸附时标题行两侧各外扩 6px 由 `useStuck`(IntersectionObserver,观察边界比吸附位置低 1px)驱动。 - 单格 grid 里放 canvas 时,轨道必须是 `grid-cols-1 grid-rows-1`(`minmax(0, 1fr)`),否则画布的像素尺寸会反过来撑大布局。 - shadcn 自带浮层(Dialog、Toast、Tooltip、DropdownMenu)与图片预览灯箱内部的定位不受此限。 +- **浮层层级**:Dialog、DropdownMenu、Tooltip 为 `z-50`,图片灯箱 `z-90`(`image-lightbox.tsx` 覆盖库默认的 9999),Toast 视口 `z-100`。通知必须盖过所有浮层,否则在弹窗内触发的 `message.*` 会被弹窗挡住;新增浮层组件不要超过 `z-90`。 ### 长列表(虚拟滚动) diff --git a/package.json b/package.json index 061d701..5b480c4 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "axios": "^1.20.0", "class-variance-authority": "^0.7.1", "cn": "^0.3.2", + "fast-xml-parser": "^5.11.2", "lucide-react": "^1.47.0", "motion": "^14.0.0", "next": "16.3.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76243b7..c2e761b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: cn: specifier: ^0.3.2 version: 0.3.2 + fast-xml-parser: + specifier: ^5.11.2 + version: 5.11.2 lucide-react: specifier: ^1.47.0 version: 1.47.0(react@19.2.8) @@ -634,6 +637,9 @@ packages: cpu: [x64] os: [win32] + '@nodable/entities@3.1.0': + resolution: {integrity: sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1094,6 +1100,9 @@ packages: resolution: {integrity: sha512-VbqNsoz55SYGczauuup0MFUyXNQviSpFTj1RQtFzmQLk18qbVSpTFFGMT293rmDaQuKCT6InmbuEyUne4mTuxQ==} engines: {node: '>=12'} + anynum@1.0.1: + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1714,6 +1723,13 @@ packages: fast-uri@3.1.8: resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + fast-xml-builder@1.3.1: + resolution: {integrity: sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==} + + fast-xml-parser@5.11.2: + resolution: {integrity: sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==} + hasBin: true + fastq@1.20.3: resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} @@ -2156,6 +2172,9 @@ packages: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} + is-unsafe@2.0.2: + resolution: {integrity: sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==} + is-weakmap@2.0.2: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} @@ -2653,6 +2672,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.6.2: + resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==} + engines: {node: '>=14.0.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -3090,6 +3113,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strnum@2.4.2: + resolution: {integrity: sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==} + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -3305,6 +3331,10 @@ packages: resolution: {integrity: sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==} engines: {node: '>=20'} + xml-naming@0.3.0: + resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} + engines: {node: '>=16.0.0'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3923,6 +3953,8 @@ snapshots: '@next/swc-win32-x64-msvc@16.3.5': optional: true + '@nodable/entities@3.1.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4377,6 +4409,8 @@ snapshots: ansi-styles@6.1.0: {} + anynum@1.0.1: {} + argparse@2.0.1: {} aria-query@5.3.2: {} @@ -5187,6 +5221,20 @@ snapshots: fast-uri@3.1.8: {} + fast-xml-builder@1.3.1: + dependencies: + path-expression-matcher: 1.6.2 + xml-naming: 0.3.0 + + fast-xml-parser@5.11.2: + dependencies: + '@nodable/entities': 3.1.0 + fast-xml-builder: 1.3.1 + is-unsafe: 2.0.2 + path-expression-matcher: 1.6.2 + strnum: 2.4.2 + xml-naming: 0.3.0 + fastq@1.20.3: dependencies: reusify: 1.1.0 @@ -5600,6 +5648,8 @@ snapshots: is-unicode-supported@2.1.0: {} + is-unsafe@2.0.2: {} + is-weakmap@2.0.2: {} is-weakref@1.1.1: @@ -6059,6 +6109,8 @@ snapshots: path-exists@4.0.0: {} + path-expression-matcher@1.6.2: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -6606,6 +6658,10 @@ snapshots: strip-json-comments@3.1.1: {} + strnum@2.4.2: + dependencies: + anynum: 1.0.1 + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.8): dependencies: client-only: 0.0.1 @@ -6844,6 +6900,8 @@ snapshots: is-wsl: 3.1.1 powershell-utils: 0.1.0 + xml-naming@0.3.0: {} + yallist@3.1.1: {} yet-another-react-lightbox@3.32.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): diff --git a/src/app/(app)/components/add-magnet-button.tsx b/src/app/(app)/components/add-magnet-button.tsx new file mode 100644 index 0000000..4079012 --- /dev/null +++ b/src/app/(app)/components/add-magnet-button.tsx @@ -0,0 +1,24 @@ +// 顶栏「添加磁力」按钮:打开添加磁力链接弹窗 +"use client"; + +import { useState } from "react"; +import { MagnetIcon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; + +import { AddMagnetDialog } from "./add-magnet-dialog"; + +export const AddMagnetButton = () => { + const [open, setOpen] = useState(false); + + return ( + <> + + + + ); +}; diff --git a/src/app/(app)/components/add-magnet-dialog.tsx b/src/app/(app)/components/add-magnet-dialog.tsx new file mode 100644 index 0000000..8fdb4b3 --- /dev/null +++ b/src/app/(app)/components/add-magnet-dialog.tsx @@ -0,0 +1,105 @@ +// 添加磁力链接弹窗:粘贴 magnet 后添加到 TorBox +"use client"; + +import { useState } from "react"; +import { CloudDownloadIcon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog"; +import { + Field, + FieldError, + FieldGroup, + FieldLabel +} from "@/components/ui/field"; +import { InputGroup, InputGroupTextarea } from "@/components/ui/input-group"; +import { Spinner } from "@/components/ui/spinner"; +import { MAX_MAGNET_LENGTH } from "@/constants/torbox"; +import { useAddMagnet } from "@/hooks/use-torrents"; +import { isMagnetLink } from "@/utils/magnet"; + +const FORM_ID = "add-magnet-form"; + +/** 弹窗每次打开都会重新挂载,输入内容随之清空 */ +const AddMagnetForm = ({ onAdded }: { onAdded: () => void }) => { + const [draft, setDraft] = useState(""); + const { mutate, isPending } = useAddMagnet(); + const magnet = draft.trim(); + const invalid = magnet !== "" && !isMagnetLink(magnet); + + return ( + <> +
{ + event.preventDefault(); + if (!magnet || invalid) return; + // 失败提示由 axios 拦截器统一处理,弹窗保持打开以便修改后重试 + mutate(magnet, { onSuccess: onAdded }); + }} + > + + + 磁力链接 + + setDraft(event.target.value)} + maxLength={MAX_MAGNET_LENGTH} + rows={4} + placeholder="magnet:?xt=urn:btih:..." + aria-invalid={invalid || undefined} + autoComplete="off" + autoFocus + spellCheck={false} + className="font-mono" + /> + + {invalid ? 请输入有效的磁力链接 : null} + + +
+ + + + + ); +}; + +type AddMagnetDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export const AddMagnetDialog = ({ + open, + onOpenChange +}: AddMagnetDialogProps) => ( + + + + 添加磁力链接 + + onOpenChange(false)} /> + + +); diff --git a/src/app/(app)/components/app-header.tsx b/src/app/(app)/components/app-header.tsx index 32d4fda..3034bf2 100644 --- a/src/app/(app)/components/app-header.tsx +++ b/src/app/(app)/components/app-header.tsx @@ -3,16 +3,20 @@ import { BrandMark } from "@/components/brand-mark"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; +import { AddMagnetButton } from "./add-magnet-button"; import { LogoutButton } from "./logout-button"; import { PlaylistMenu } from "./playlist-menu"; +import { SearchButton } from "./search-button"; type AppHeaderProps = { token: string; /** 演示模式:未配置 TORBOX_API_KEY,资源库为模拟数据 */ mock: boolean; + /** 已配置 Jackett(或演示模式);否则不显示搜索入口 */ + searchEnabled: boolean; }; -export const AppHeader = ({ token, mock }: AppHeaderProps) => ( +export const AppHeader = ({ token, mock, searchEnabled }: AppHeaderProps) => (
@@ -27,6 +31,8 @@ export const AppHeader = ({ token, mock }: AppHeaderProps) => ( )}
+ + {searchEnabled && } diff --git a/src/app/(app)/components/search-button.tsx b/src/app/(app)/components/search-button.tsx new file mode 100644 index 0000000..e4f0d78 --- /dev/null +++ b/src/app/(app)/components/search-button.tsx @@ -0,0 +1,60 @@ +// 顶栏搜索按钮:打开搜索弹窗,并在按钮上显示后台搜索的进度与完成提示 +"use client"; + +import { CheckIcon, SearchIcon, TriangleAlertIcon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { cn } from "@/lib/utils"; +import { useSearchStore } from "@/stores/search-store"; + +import { SearchDialog } from "./search-dialog"; + +export const SearchButton = () => { + const status = useSearchStore((state) => state.status); + const total = useSearchStore((state) => state.total); + const completed = useSearchStore((state) => state.completed); + const unseen = useSearchStore((state) => state.unseen); + const setOpen = useSearchStore((state) => state.setOpen); + + const searching = status === "searching"; + // 完成/失败后一直保持到用户打开弹窗看过为止 + const finished = unseen && status === "done"; + const failed = unseen && status === "failed"; + + return ( + <> + + + + ); +}; diff --git a/src/app/(app)/components/search-dialog.tsx b/src/app/(app)/components/search-dialog.tsx new file mode 100644 index 0000000..36efa5d --- /dev/null +++ b/src/app/(app)/components/search-dialog.tsx @@ -0,0 +1,256 @@ +// 搜索弹窗:关键词、进度、失败站点与结果列表;关闭弹窗不会中断搜索(状态在 search-store) +"use client"; + +import { useState } from "react"; +import { + ChevronDownIcon, + ListFilterIcon, + SearchIcon, + TriangleAlertIcon +} from "lucide-react"; + +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger +} from "@/components/ui/collapsible"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle +} from "@/components/ui/empty"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput +} from "@/components/ui/input-group"; +import { + Progress, + ProgressLabel, + ProgressValue +} from "@/components/ui/progress"; +import { Skeleton } from "@/components/ui/skeleton"; +import { MAX_QUERY_LENGTH } from "@/constants/search"; +import { useSearchResults } from "@/hooks/use-search-results"; +import { cn } from "@/lib/utils"; +import { useSearchStore, type SearchStatus } from "@/stores/search-store"; +import { useSearchViewStore } from "@/stores/search-view-store"; + +import { SearchResultList } from "./search-result-list"; +import { SearchResultToolbar } from "./search-result-toolbar"; + +/** 结果区固定高度:弹窗不随结果数量忽高忽低 */ +const RESULT_HEIGHT = "h-[min(40rem,60dvh)]"; + +const progressLabel = (status: SearchStatus, resultCount: number) => { + switch (status) { + case "searching": + return `正在搜索 · 已有 ${resultCount} 条结果`; + case "done": + return `搜索完成 · 共 ${resultCount} 条结果`; + case "cancelled": + return `已取消 · 已有 ${resultCount} 条结果`; + default: + return `搜索失败 · 已有 ${resultCount} 条结果`; + } +}; + +const SearchForm = () => { + const status = useSearchStore((state) => state.status); + const start = useSearchStore((state) => state.start); + const cancel = useSearchStore((state) => state.cancel); + // 弹窗每次打开都会重新挂载,从最近一次搜索的关键词继续 + const [draft, setDraft] = useState(() => useSearchStore.getState().query); + const keyword = draft.trim(); + + return ( +
{ + event.preventDefault(); + if (!keyword) return; + // 新结果与旧的筛选词无关,清掉避免一上来就是空列表 + useSearchViewStore.getState().setKeyword(""); + void start(keyword); + }} + > + + + + + setDraft(event.target.value)} + maxLength={MAX_QUERY_LENGTH} + placeholder="输入关键词,如片名" + aria-label="搜索关键词" + autoComplete="off" + /> + + {status === "searching" ? ( + + 取消 + + ) : ( + + 搜索 + + )} + + +
+ ); +}; + +const SearchProgress = () => { + const status = useSearchStore((state) => state.status); + const total = useSearchStore((state) => state.total); + const completed = useSearchStore((state) => state.completed); + const resultCount = useSearchStore((state) => state.results.length); + const failures = useSearchStore((state) => state.failures); + const error = useSearchStore((state) => state.error); + + if (status === "idle") return null; + + return ( +
+ + {progressLabel(status, resultCount)} + + {() => (total ? `${completed} / ${total} 个站点` : "")} + + + + {status === "failed" && error && ( + + + {error} + + )} + + {failures.length > 0 && ( + + + {failures.length} 个站点没有返回结果 + + + +
    + {failures.map((failure) => ( +
  • + {failure.indexerName} — {failure.error} +
  • + ))} +
+
+
+ )} +
+ ); +}; + +const SearchResults = () => { + const status = useSearchStore((state) => state.status); + const results = useSearchStore((state) => state.results); + const sortKey = useSearchViewStore((state) => state.sortKey); + const order = useSearchViewStore((state) => state.order); + const setKeyword = useSearchViewStore((state) => state.setKeyword); + const { items, terms } = useSearchResults(results); + + if (results.length > 0) + return ( +
+ + {items.length > 0 ? ( + + ) : ( + + + + + + 没有匹配的结果 + + 共 {results.length} 条结果中没有同时包含这些词的 + + + + + )} +
+ ); + + if (status === "searching") + return ( +
+ {Array.from({ length: 10 }, (_, index) => ( + + ))} +
+ ); + + const idle = status === "idle"; + return ( + + + + + + {idle ? "输入关键词开始搜索" : "没有找到结果"} + {idle ? "" : "换个关键词试试"} + + + ); +}; + +export const SearchDialog = () => { + const open = useSearchStore((state) => state.open); + const setOpen = useSearchStore((state) => state.setOpen); + + return ( + + {/* grid-cols-1:轨道可被压缩,内容再宽也不会把弹窗撑出屏幕 */} + + + 搜索资源 + + + + + + + ); +}; diff --git a/src/app/(app)/components/search-result-list.tsx b/src/app/(app)/components/search-result-list.tsx new file mode 100644 index 0000000..b788c80 --- /dev/null +++ b/src/app/(app)/components/search-result-list.tsx @@ -0,0 +1,226 @@ +// 搜索结果列表(虚拟滚动):结果可达数千条,行高固定,只渲染可见行 +"use client"; + +import { memo, useState } from "react"; +import { + ArrowDownIcon, + ArrowUpIcon, + CheckIcon, + CloudDownloadIcon, + CopyIcon, + ExternalLinkIcon, + type LucideIcon +} from "lucide-react"; + +import { HighlightText } from "@/components/highlight-text"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { + Tooltip, + TooltipContent, + TooltipTrigger +} from "@/components/ui/tooltip"; +import { VirtualList } from "@/components/virtual-list"; +import { useCopy } from "@/hooks/use-copy"; +import { useAddMagnet } from "@/hooks/use-torrents"; +import { cn } from "@/lib/utils"; +import { useSearchStore } from "@/stores/search-store"; +import type { SearchResult } from "@/types/search"; +import { formatRelativeDate } from "@/utils/date"; +import { formatBytes, formatCount } from "@/utils/format"; + +/** 名称完整换行显示,行高不固定;这是未测量前的估计值(日期行 + 单行名称 + 底栏) */ +const ESTIMATED_ROW_HEIGHT = 108; + +type RowActionProps = { + label: string; + icon: LucideIcon; + /** 进行中:显示 Spinner 并禁用 */ + pending?: boolean; + disabled?: boolean; + /** 链接型操作:在新标签页打开 */ + href?: string; + onClick?: () => void; + className?: string; +}; + +/** 行尾的图标操作:只显示图标,名称放在 Tooltip 与 aria-label */ +const RowAction = ({ + label, + icon: Icon, + pending = false, + disabled = false, + href, + onClick, + className +}: RowActionProps) => ( + + + ) + } + : {})} + /> + } + > + {pending ? : } + + {label} + +); + +/** 每行都能跳转到站点;有 magnet 时另有复制、添加到 TorBox */ +const ResultActions = ({ result }: { result: SearchResult }) => { + const copy = useCopy(); + const status = useSearchStore((state) => state.torrentStatus[result.id]); + const setTorrentStatus = useSearchStore((state) => state.setTorrentStatus); + const { mutate: addToTorbox } = useAddMagnet({ + onMutate: () => setTorrentStatus(result.id, "adding"), + onSuccess: () => setTorrentStatus(result.id, "added"), + onError: () => setTorrentStatus(result.id) + }); + const { magnet, detailsUrl } = result; + + return ( + <> + {magnet && ( + <> + copy(magnet, "已复制 magnet 链接")} + /> + addToTorbox(magnet)} + /> + + )} + {detailsUrl && ( + + )} + + ); +}; + +/** 做种 / 下载数:只显示图标与数字,名称放在 title 与读屏文字里 */ +const Stat = ({ + label, + icon: Icon, + value +}: { + label: string; + icon: LucideIcon; + value: number; +}) => ( + + + {label} + {formatCount(value)} + +); + +const ResultRow = memo(function ResultRow({ + result, + terms, + now +}: { + result: SearchResult; + terms: readonly string[]; + now: number; +}) { + const { publishedAt, size, seeders, leechers } = result; + + return ( +
+
+ {/* 站点没给发布时间时留空,来源仍靠右 */} + + {publishedAt ? formatRelativeDate(publishedAt, now) : null} + + + {result.indexerName} + +
+ {/* 名称不省略;没有空格的超长名称也要能在容器内断行 */} + + + +
+
+ {size != null && {formatBytes(size)}} + {seeders != null && ( + + )} + {leechers != null && ( + + )} +
+ {/* 窄屏换到下一行,靠右 */} +
+ +
+
+
+ ); +}); + +type SearchResultListProps = { + /** 已筛选、排序好的结果 */ + items: SearchResult[]; + /** 高亮的筛选词 */ + terms: readonly string[]; + /** 筛选或排序条件变化时滚回顶部 */ + resetScrollKey: string; + className?: string; +}; + +export const SearchResultList = ({ + items, + terms, + resetScrollKey, + className +}: SearchResultListProps) => { + // 相对日期以列表挂载时刻为「现在」;放在这里是为了不让每一行各自取时间 + const [now] = useState(() => Date.now()); + + return ( + result.id} + estimateSize={ESTIMATED_ROW_HEIGHT} + renderItem={(result) => ( + + )} + resetScrollKey={resetScrollKey} + className={className} + /> + ); +}; diff --git a/src/app/(app)/components/search-result-toolbar.tsx b/src/app/(app)/components/search-result-toolbar.tsx new file mode 100644 index 0000000..d63e4bb --- /dev/null +++ b/src/app/(app)/components/search-result-toolbar.tsx @@ -0,0 +1,156 @@ +// 搜索结果工具栏:在已有结果里筛选,以及排序方式与方向 +"use client"; + +import { + ArrowDownIcon, + ArrowDownUpIcon, + ArrowUpIcon, + ListFilterIcon, + XIcon +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from "@/components/ui/dropdown-menu"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, + InputGroupText +} from "@/components/ui/input-group"; +import { SEARCH_SORT_OPTIONS } from "@/constants/search"; +import { useSearchViewStore } from "@/stores/search-view-store"; +import type { SearchSortKey, SortOrder } from "@/types/search"; + +const ORDER_OPTIONS: { value: SortOrder; label: string }[] = [ + { value: "asc", label: "升序" }, + { value: "desc", label: "降序" } +]; + +const SortMenu = () => { + const sortKey = useSearchViewStore((state) => state.sortKey); + const order = useSearchViewStore((state) => state.order); + const setSortKey = useSearchViewStore((state) => state.setSortKey); + const setOrder = useSearchViewStore((state) => state.setOrder); + const label = SEARCH_SORT_OPTIONS.find( + (option) => option.value === sortKey + )?.label; + const orderLabel = ORDER_OPTIONS.find( + (option) => option.value === order + )?.label; + + return ( + + + } + > + + {label} + {order === "asc" ? ( + + ) : ( + + )} + + + + 排序方式 + setSortKey(value)} + > + {SEARCH_SORT_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + + + 顺序 + setOrder(value)} + > + {ORDER_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + + + ); +}; + +type SearchResultToolbarProps = { + /** 本次搜索的结果总数 */ + total: number; + /** 筛选后剩余的条数 */ + shown: number; +}; + +export const SearchResultToolbar = ({ + total, + shown +}: SearchResultToolbarProps) => { + const keyword = useSearchViewStore((state) => state.keyword); + const setKeyword = useSearchViewStore((state) => state.setKeyword); + + return ( +
+ + + + + setKeyword(event.target.value)} + placeholder="在结果中筛选,空格分隔多个词" + aria-label="在结果中筛选" + autoComplete="off" + /> + {keyword && ( + + + {shown} / {total} + + setKeyword("")} + > + + + + )} + + +
+ ); +}; diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 5a03a43..2983417 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -1,5 +1,6 @@ // 登录后页面布局 import { isMockMode } from "@/server/env"; +import { isSearchAvailable } from "@/server/search"; import { requireSessionToken } from "@/server/session"; import { AppHeader } from "./components/app-header"; @@ -10,7 +11,11 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => { return (
- +
{children}
); diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts new file mode 100644 index 0000000..2144173 --- /dev/null +++ b/src/app/api/search/route.ts @@ -0,0 +1,50 @@ +// 搜索接口:并行查询全部 Indexer,以 NDJSON 流逐个交付结果 +import type { NextRequest } from "next/server"; +import { MAX_QUERY_LENGTH } from "@/constants/search"; +import { guard } from "@/server/auth"; +import { JackettError } from "@/server/jackett"; +import { createLogger } from "@/server/logger"; +import { jsonError, ndjsonResponse, withApi } from "@/server/response"; +import { + getSearchIndexers, + isSearchAvailable, + runSearch +} from "@/server/search"; +import type { SearchEvent } from "@/types/search"; + +const log = createLogger("search"); + +export const dynamic = "force-dynamic"; + +export const GET = withApi(async (req: NextRequest) => { + const denied = guard(req); + if (denied) return denied; + if (!isSearchAvailable()) return jsonError(503, "未配置 Jackett"); + + const query = req.nextUrl.searchParams.get("q")?.trim() ?? ""; + if (!query) return jsonError(400, "请输入搜索关键词"); + if (query.length > MAX_QUERY_LENGTH) + return jsonError(400, `关键词不能超过 ${MAX_QUERY_LENGTH} 个字符`); + + // 先取 Indexer 列表:失败时还没开始写流,可以返回正常的错误响应 + let indexers; + try { + indexers = await getSearchIndexers(req.signal); + } catch (error) { + log.error({ err: error }, "获取 Indexer 列表失败"); + return jsonError( + 502, + error instanceof JackettError + ? `Jackett 返回错误:${error.message}` + : "无法连接 Jackett" + ); + } + if (indexers.length === 0) + return jsonError(404, "Jackett 中没有已配置的 Indexer"); + + return ndjsonResponse(req, async (send, signal) => { + send({ type: "start", total: indexers.length }); + await runSearch(indexers, query, signal, send); + send({ type: "done" }); + }); +}); diff --git a/src/app/api/torrents/route.ts b/src/app/api/torrents/route.ts new file mode 100644 index 0000000..1a0d329 --- /dev/null +++ b/src/app/api/torrents/route.ts @@ -0,0 +1,35 @@ +// 添加 torrent 接口(magnet) +import type { NextRequest } from "next/server"; +import { MAX_MAGNET_LENGTH } from "@/constants/torbox"; +import { guard } from "@/server/auth"; +import { createLogger } from "@/server/logger"; +import { jsonError, jsonOk, withApi } from "@/server/response"; +import { addMagnet } from "@/server/torbox"; +import { isMagnetLink } from "@/utils/magnet"; + +const log = createLogger("torrents"); + +export const dynamic = "force-dynamic"; + +export const POST = withApi(async (req: NextRequest) => { + const denied = guard(req); + if (denied) return denied; + + const body = await req.json().catch(() => ({})); + const magnet = typeof body.magnet === "string" ? body.magnet.trim() : ""; + if (!magnet || magnet.length > MAX_MAGNET_LENGTH || !isMagnetLink(magnet)) + return jsonError(400, "magnet 链接格式不正确"); + + try { + const result = await addMagnet(magnet); + log.info({ id: result.id, hash: result.hash }, "已添加 magnet"); + return jsonOk(result); + } catch (error) { + // TorBox 的失败原因(名额已满、限流等)对用户有意义,原样透出 + log.error({ err: error }, "添加 magnet 失败"); + return jsonError( + 502, + error instanceof Error ? error.message : "upstream error" + ); + } +}); diff --git a/src/components/highlight-text.tsx b/src/components/highlight-text.tsx new file mode 100644 index 0000000..a3cc6c0 --- /dev/null +++ b/src/components/highlight-text.tsx @@ -0,0 +1,38 @@ +// 高亮文本中命中的关键词(不区分大小写) +import { Fragment } from "react"; + +const escapeRegExp = (value: string) => + value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +type HighlightTextProps = { + text: string; + /** 要高亮的词;为空时原样输出 */ + terms: readonly string[]; +}; + +export const HighlightText = ({ text, terms }: HighlightTextProps) => { + if (terms.length === 0) return text; + + // 长词优先,避免「ubuntu」被「ub」抢先匹配后只亮一半 + const pattern = [...terms] + .sort((a, b) => b.length - a.length) + .map(escapeRegExp) + .join("|"); + // 带捕获组的 split:奇数下标的片段就是命中的部分 + const parts = text.split(new RegExp(`(${pattern})`, "i")); + + return parts.map((part, index) => + index % 2 === 1 ? ( + // 实心翠绿 + 深墨字(对比度 8.2:1);半透明底在深色卡片上几乎看不出来。 + // box-decoration-clone:命中词跨行断开时,每一段都保留完整的圆角与内边距 + + {part} + + ) : ( + {part} + ) + ); +}; diff --git a/src/components/image-lightbox.tsx b/src/components/image-lightbox.tsx index 817c52e..e57925e 100644 --- a/src/components/image-lightbox.tsx +++ b/src/components/image-lightbox.tsx @@ -94,6 +94,8 @@ const SINGLE_RENDER: Render = { /** 灯箱配色映射到现有主题变量,不新增颜色 */ const STYLES: SlotStyles = { root: { + // 层级:浮层 z-50 < 灯箱 < 通知 z-100;库默认 9999 会盖住通知 + "--yarl__portal_zindex": 90, "--yarl__color_backdrop": "var(--background)", "--yarl__color_button": "var(--muted-foreground)", "--yarl__color_button_active": "var(--foreground)", diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index f200244..f292e66 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -29,7 +29,9 @@ function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) { = { + items: readonly T[]; + /** 稳定的行标识;重新排序后已测量的行高会跟着行走 */ + getItemKey: (item: T) => string | number; + /** 未测量前的估计行高(px),越接近实际值滚动条越稳 */ + estimateSize: number; + renderItem: (item: T) => ReactNode; + overscan?: number; + /** 值变化时滚回顶部,如筛选、排序条件改变;流式追加数据不应改变它 */ + resetScrollKey?: string; + className?: string; +}; + +export const VirtualList = ({ + items, + getItemKey, + estimateSize, + renderItem, + overscan = 8, + resetScrollKey, + className +}: VirtualListProps) => { + const scrollRef = useRef(null); + + // eslint-disable-next-line react-hooks/incompatible-library -- 项目未启用 React Compiler;虚拟器每次渲染都需读取最新滚动状态 + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollRef.current, + getItemKey: (index) => getItemKey(items[index]), + estimateSize: () => estimateSize, + overscan + }); + + useEffect(() => { + scrollRef.current?.scrollTo({ top: 0 }); + }, [resetScrollKey]); + + const rows = virtualizer.getVirtualItems(); + const top = rows[0]?.start ?? 0; + const bottom = virtualizer.getTotalSize() - (rows.at(-1)?.end ?? 0); + + return ( +
+
+ {rows.map((row) => ( + // measureElement 读取 data-index 并监听尺寸变化 +
+ {renderItem(items[row.index])} +
+ ))} +
+
+ ); +}; diff --git a/src/constants/search.ts b/src/constants/search.ts new file mode 100644 index 0000000..7ffba3f --- /dev/null +++ b/src/constants/search.ts @@ -0,0 +1,28 @@ +// 搜索相关常量 +import type { SearchSortKey, SortOrder } from "@/types/search"; + +export const SEARCH_API = "/search"; + +/** 同时请求的 Indexer 数量上限 */ +export const SEARCH_CONCURRENCY = 12; + +/** 单个 Indexer 的超时:实测多数 3 秒内返回,少数要 10–20 秒,其余会卡到 Jackett 自身的 60 秒 */ +export const INDEXER_TIMEOUT_MS = 30_000; + +/** Indexer 列表缓存时长,配置变更后最多这么久生效 */ +export const INDEXER_CACHE_MS = 5 * 60 * 1000; + +export const MAX_QUERY_LENGTH = 100; + +/** defaultOrder:切换到该排序方式时的初始方向(名称从 A 到 Z,其余数值大的在前) */ +export const SEARCH_SORT_OPTIONS: { + value: SearchSortKey; + label: string; + defaultOrder: SortOrder; +}[] = [ + { value: "name", label: "名称", defaultOrder: "asc" }, + { value: "seeders", label: "做种者", defaultOrder: "desc" }, + { value: "leechers", label: "下载者", defaultOrder: "desc" }, + { value: "size", label: "文件大小", defaultOrder: "desc" }, + { value: "date", label: "日期", defaultOrder: "desc" } +]; diff --git a/src/constants/torbox.ts b/src/constants/torbox.ts index 52cc1e8..f6457fe 100644 --- a/src/constants/torbox.ts +++ b/src/constants/torbox.ts @@ -25,6 +25,9 @@ export const SOURCES = [ } ] as const; +/** magnet 含 tracker 与名称,长度有限;超出视为异常输入(前后端共用) */ +export const MAX_MAGNET_LENGTH = 8192; + export const IMAGE_EXT = new Set([ "jpg", "jpeg", diff --git a/src/hooks/use-search-results.ts b/src/hooks/use-search-results.ts new file mode 100644 index 0000000..1958b0f --- /dev/null +++ b/src/hooks/use-search-results.ts @@ -0,0 +1,57 @@ +// 搜索结果的筛选与排序 +import { useDeferredValue, useMemo } from "react"; + +import { useSearchViewStore } from "@/stores/search-view-store"; +import type { SearchResult, SearchSortKey, SortOrder } from "@/types/search"; +import { toTimestamp } from "@/utils/date"; +import { compareByNumber, compareNatural, type Comparator } from "@/utils/sort"; + +/** 缺少字段的结果始终排在最后,与方向无关 */ +const getComparator = ( + key: SearchSortKey, + order: SortOrder +): Comparator => { + switch (key) { + case "name": + return (a, b) => + (order === "asc" ? 1 : -1) * compareNatural(a.title, b.title); + case "seeders": + return compareByNumber((result) => result.seeders, order); + case "leechers": + return compareByNumber((result) => result.leechers, order); + case "size": + return compareByNumber((result) => result.size, order); + case "date": + return compareByNumber( + (result) => toTimestamp(result.publishedAt), + order + ); + } +}; + +/** 筛选并排序后的结果;terms 是筛选词(小写),供列表高亮命中的部分 */ +export const useSearchResults = (results: SearchResult[]) => { + const keyword = useSearchViewStore((state) => state.keyword); + const sortKey = useSearchViewStore((state) => state.sortKey); + const order = useSearchViewStore((state) => state.order); + // 输入时先让输入框响应,几千条结果的筛选稍后再算 + const deferredKeyword = useDeferredValue(keyword); + + const terms = useMemo( + () => deferredKeyword.toLowerCase().split(/\s+/).filter(Boolean), + [deferredKeyword] + ); + + const items = useMemo(() => { + const matched = terms.length + ? results.filter((result) => { + const title = result.title.toLowerCase(); + return terms.every((term) => title.includes(term)); + }) + : [...results]; + // 稳定排序:相等时保留结果到达的顺序 + return matched.sort(getComparator(sortKey, order)); + }, [results, terms, sortKey, order]); + + return { items, terms }; +}; diff --git a/src/hooks/use-torrents.ts b/src/hooks/use-torrents.ts new file mode 100644 index 0000000..d16d2e3 --- /dev/null +++ b/src/hooks/use-torrents.ts @@ -0,0 +1,36 @@ +// torrent 相关 hook +import { useMutation } from "@tanstack/react-query"; + +import { useRefreshLibrary } from "@/hooks/use-library"; +import { message } from "@/lib/message"; +import { addMagnet } from "@/services/torrents"; + +type UseAddMagnetOptions = { + onMutate?: () => void; + onSuccess?: () => void; + onError?: () => void; +}; + +/** + * 添加 magnet 到 TorBox:成功后提示并刷新资源库,失败提示由 axios 拦截器统一处理。 + * 回调通过 hook 参数传入而不是 mutate():mutate() 的回调在组件卸载后不会触发, + * 而调用方(虚拟滚动的结果行、已关闭的弹窗)随时可能卸载,状态仍要正确收尾。 + */ +export const useAddMagnet = ({ + onMutate, + onSuccess, + onError +}: UseAddMagnetOptions = {}) => { + const refreshLibrary = useRefreshLibrary(); + return useMutation({ + mutationFn: addMagnet, + onMutate: () => onMutate?.(), + onSuccess: () => { + message.success("已添加到 TorBox"); + // TorBox 的 torrent 列表默认 600 秒才更新,新添加的资源要绕过缓存才能看到 + void refreshLibrary(); + onSuccess?.(); + }, + onError: () => onError?.() + }); +}; diff --git a/src/server/env.ts b/src/server/env.ts index dfedbac..e4e831c 100644 --- a/src/server/env.ts +++ b/src/server/env.ts @@ -4,6 +4,8 @@ import { MOCK_ACCESS_TOKEN } from "@/constants/auth"; type ServerEnvName = | "ACCESS_TOKEN" | "TORBOX_API_KEY" + | "JACKETT_URL" + | "JACKETT_API_KEY" | "LOG_LEVEL" | "LOG_DIR" | "LOG_RETAIN_DAYS"; @@ -51,6 +53,24 @@ export const getTorboxApiKey = () => { return key; }; +/** Jackett 是否已配置(地址与 API Key 缺一不可);未配置时搜索不可用 */ +export const isJackettConfigured = () => + Boolean(readEnv("JACKETT_URL") && readEnv("JACKETT_API_KEY")); + +/** Jackett 地址,去掉末尾斜杠,如 http://192.168.1.10:9117 */ +export const getJackettUrl = () => { + const url = readEnv("JACKETT_URL")?.trim().replace(/\/+$/, ""); + if (!url) throw new Error("未配置 JACKETT_URL"); + return url; +}; + +/** Jackett API Key,仅服务端使用 */ +export const getJackettApiKey = () => { + const key = readEnv("JACKETT_API_KEY"); + if (!key) throw new Error("未配置 JACKETT_API_KEY"); + return key; +}; + /** 日志级别(LOG_LEVEL),未配置时由日志模块按运行环境决定 */ export const getLogLevel = () => readEnv("LOG_LEVEL")?.trim().toLowerCase(); @@ -65,6 +85,8 @@ export const getLogRetainDays = () => { /** 实际配置的密钥值,供日志脱敏使用;演示模式的固定密钥不是机密,不在其中 */ export const getSecretValues = () => - [readEnv("ACCESS_TOKEN"), readEnv("TORBOX_API_KEY")].filter( - (value): value is string => Boolean(value) - ); + [ + readEnv("ACCESS_TOKEN"), + readEnv("TORBOX_API_KEY"), + readEnv("JACKETT_API_KEY") + ].filter((value): value is string => Boolean(value)); diff --git a/src/server/jackett.ts b/src/server/jackett.ts new file mode 100644 index 0000000..e72acb6 --- /dev/null +++ b/src/server/jackett.ts @@ -0,0 +1,149 @@ +// Jackett(Torznab)服务端封装 +// +// 请求地址带 API Key,所以: +// - 日志只记 Indexer id,不记 URL +// - 结果只读取白名单字段;`link`、`enclosure` 是 Jackett 的代理下载地址,含 API Key,绝不下发 +import { XMLParser } from "fast-xml-parser"; + +import { INDEXER_CACHE_MS } from "@/constants/search"; +import { getJackettApiKey, getJackettUrl } from "@/server/env"; +import type { SearchResult } from "@/types/search"; + +export type Indexer = { id: string; name: string }; + +/** Jackett 报告的错误(HTTP 200 + ,或 4xx 带 ) */ +export class JackettError extends Error {} + +const ARRAY_TAGS = new Set(["indexer", "item", "torznab:attr"]); + +const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "@_", + // 标题、id 保持原样,不转成数字 + parseTagValue: false, + parseAttributeValue: false, + isArray: (name) => ARRAY_TAGS.has(name) +}); + +type RawXml = Record; // eslint-disable-line @typescript-eslint/no-explicit-any + +/** Jackett 的错误描述含整段堆栈甚至 HTML,只保留第一句 */ +const summarizeError = (description: unknown) => { + const text = String(description ?? "") + .replace(/^[\s\S]*?Exception \([^)]*\):\s*/, "") + .split(/\s+Response=|\s+--->|\r?\n|&#x[0-9a-f]+;/i)[0] + .trim(); + return text.slice(0, 120) || "未知错误"; +}; + +const jackettFetch = async ( + path: string, + params: URLSearchParams, + signal: AbortSignal +) => { + params.set("apikey", getJackettApiKey()); + const res = await fetch( + `${getJackettUrl()}/api/v2.0/indexers/${path}/results/torznab/api?${params}`, + { + signal, + cache: "no-store" + } + ); + const doc: RawXml = parser.parse(await res.text()); + if (doc.error) + throw new JackettError(summarizeError(doc.error["@_description"])); + if (!res.ok) throw new JackettError(`HTTP ${res.status}`); + return doc; +}; + +let indexerCache: { at: number; data: Promise } | null = null; + +/** 已配置的 Indexer;缓存的是 Promise,并发请求共用同一次拉取,失败不缓存 */ +export const listIndexers = (signal: AbortSignal) => { + if (indexerCache && Date.now() - indexerCache.at < INDEXER_CACHE_MS) + return indexerCache.data; + const data = jackettFetch( + "all", + new URLSearchParams({ t: "indexers", configured: "true" }), + signal + ).then((doc): Indexer[] => + (doc.indexers?.indexer ?? []).map((indexer: RawXml) => ({ + id: indexer["@_id"], + name: indexer.title ?? indexer["@_id"] + })) + ); + const entry = { at: Date.now(), data }; + indexerCache = entry; + data.catch(() => { + if (indexerCache === entry) indexerCache = null; + }); + return data; +}; + +const toNumber = (value: unknown) => { + const number = Number(value); + return value != null && value !== "" && Number.isFinite(number) + ? number + : null; +}; + +const toIso = (value: unknown) => { + const time = Date.parse(String(value ?? "")); + return Number.isNaN(time) ? null : new Date(time).toISOString(); +}; + +const toResult = (item: RawXml, indexer: Indexer): SearchResult | null => { + const title = String(item.title ?? "").trim(); + if (!title) return null; + + const attrs = new Map( + (item["torznab:attr"] ?? []).map((attr: RawXml) => [ + attr["@_name"], + attr["@_value"] + ]) + ); + const infohash = attrs.get("infohash")?.toLowerCase() || null; + const link = String(item.link ?? ""); + const direct = [attrs.get("magneturl"), link].find((url) => + url?.startsWith("magnet:") + ); + const magnet = + direct ?? + (infohash + ? `magnet:?xt=urn:btih:${infohash}&dn=${encodeURIComponent(title)}` + : null); + const comments = String(item.comments ?? ""); + const seeders = toNumber(attrs.get("seeders")); + const peers = toNumber(attrs.get("peers")); + const size = toNumber(item.size); + + return { + id: infohash ?? `${indexer.id}:${item.guid ?? title}`, + title, + indexerId: indexer.id, + indexerName: indexer.name, + // 有的站点(如 Uindex)不提供大小,Jackett 填 0;真实的种子不会是 0 字节,按未知处理 + size: size && size > 0 ? size : null, + seeders, + leechers: + seeders != null && peers != null ? Math.max(0, peers - seeders) : null, + publishedAt: toIso(item.pubDate), + detailsUrl: /^https?:\/\//.test(comments) ? comments : null, + magnet + }; +}; + +/** 单个 Indexer 的搜索结果;失败(站点出错、超时)以异常抛出 */ +export const queryIndexer = async ( + indexer: Indexer, + query: string, + signal: AbortSignal +): Promise => { + const doc = await jackettFetch( + indexer.id, + new URLSearchParams({ t: "search", q: query }), + signal + ); + const items: RawXml[] = doc.rss?.channel?.item ?? []; + return items.flatMap((item) => toResult(item, indexer) ?? []); +}; diff --git a/src/server/mock-search.ts b/src/server/mock-search.ts new file mode 100644 index 0000000..8c3bf44 --- /dev/null +++ b/src/server/mock-search.ts @@ -0,0 +1,88 @@ +// 演示模式的模拟搜索:几个速度、结果数、成败各异的 Indexer,用来观察流式进度 +import type { Indexer } from "@/server/jackett"; +import type { SearchResult } from "@/types/search"; + +const MB = 1024 ** 2; + +type MockIndexer = Indexer & { + delayMs: number; + count: number; + /** 有值则该 Indexer 失败 */ + error?: string; +}; + +const MOCK_INDEXERS: MockIndexer[] = [ + { id: "mock-fast", name: "Fast Tracker", delayMs: 400, count: 8 }, + { id: "mock-medium", name: "Medium Index", delayMs: 1500, count: 14 }, + { id: "mock-empty", name: "Empty Site", delayMs: 900, count: 0 }, + { + id: "mock-blocked", + name: "Blocked Site", + delayMs: 1100, + count: 0, + error: "Error Parsing Json Response: Status=Forbidden" + }, + { id: "mock-slow", name: "Slow Archive", delayMs: 5000, count: 20 }, + { id: "mock-big", name: "Big Pile", delayMs: 2500, count: 30 }, + { id: "mock-late", name: "Late Mirror", delayMs: 9000, count: 6 } +]; + +export const getMockIndexers = (): Indexer[] => + MOCK_INDEXERS.map(({ id, name }) => ({ id, name })); + +const QUALITIES = [ + "2160p UHD BluRay x265", + "1080p WEB-DL H264", + "720p HDTV x264", + "1080p BluRay REMUX" +]; + +const hex = (seed: number) => + Array.from({ length: 40 }, (_, i) => + ((seed * 31 + i * 17) % 16).toString(16) + ).join(""); + +export const queryMockIndexer = ( + indexer: Indexer, + query: string, + signal: AbortSignal +): Promise => + new Promise((resolve, reject) => { + const mock = MOCK_INDEXERS.find((item) => item.id === indexer.id)!; + const timer = setTimeout(() => { + if (mock.error) return reject(new Error(mock.error)); + resolve( + Array.from({ length: mock.count }, (_, index) => { + const seed = mock.delayMs + index; + const title = `${query} ${QUALITIES[index % QUALITIES.length]}`; + const infohash = hex(seed); + return { + id: infohash, + title, + indexerId: mock.id, + indexerName: mock.name, + size: (300 + ((seed * 7919) % 6000)) * MB, + seeders: (seed * 13) % 400, + leechers: (seed * 17) % 90, + publishedAt: new Date( + Date.now() - (index + 1) * 36 * 3600 * 1000 + ).toISOString(), + detailsUrl: `https://example.com/torrent/${infohash}`, + // 一部分结果模拟「站点未直接给出 magnet」 + magnet: + index % 3 === 2 + ? null + : `magnet:?xt=urn:btih:${infohash}&dn=${encodeURIComponent(title)}` + }; + }) + ); + }, mock.delayMs); + signal.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject(signal.reason); + }, + { once: true } + ); + }); diff --git a/src/server/response.ts b/src/server/response.ts index 0a7dab1..88a1940 100644 --- a/src/server/response.ts +++ b/src/server/response.ts @@ -34,3 +34,45 @@ export const withApi = return jsonError(500, "服务器内部错误"); } }; + +/** + * NDJSON 流式响应:每次 send 写出一行 JSON。 + * - 客户端断开或取消读取时 signal 中止,run 应据此停止上游请求;run 结束后流自动关闭 + * - no-transform 防止被压缩层攒包,X-Accel-Buffering 让 nginx 不缓冲 + */ +export const ndjsonResponse = ( + req: Request, + run: (send: (event: T) => void, signal: AbortSignal) => Promise +) => { + const abort = new AbortController(); + req.signal.addEventListener("abort", () => abort.abort(), { once: true }); + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + const send = (event: T) => { + if (!abort.signal.aborted) + controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`)); + }; + try { + await run(send, abort.signal); + } catch (error) { + if (!abort.signal.aborted) + log.error({ err: error }, "流式接口异常中断"); + } finally { + if (!abort.signal.aborted) controller.close(); + } + }, + cancel() { + abort.abort(); + } + }); + + return new Response(stream, { + headers: { + "Content-Type": "application/x-ndjson; charset=utf-8", + "Cache-Control": "no-store, no-transform", + "X-Accel-Buffering": "no" + } + }); +}; diff --git a/src/server/search.ts b/src/server/search.ts new file mode 100644 index 0000000..f8ba43c --- /dev/null +++ b/src/server/search.ts @@ -0,0 +1,103 @@ +// 并行搜索所有 Indexer,谁先返回谁先交付 +import { INDEXER_TIMEOUT_MS, SEARCH_CONCURRENCY } from "@/constants/search"; +import { isJackettConfigured, isMockMode } from "@/server/env"; +import { listIndexers, queryIndexer, type Indexer } from "@/server/jackett"; +import { createLogger } from "@/server/logger"; +import { getMockIndexers, queryMockIndexer } from "@/server/mock-search"; +import type { SearchEvent, SearchResult } from "@/types/search"; + +const log = createLogger("search"); + +/** 搜索是否可用:配置了 Jackett,或处于演示模式(使用模拟数据) */ +export const isSearchAvailable = () => isJackettConfigured() || isMockMode(); + +/** 已配置 Jackett 时一律走真实接口,演示模式只在没有 Jackett 时兜底 */ +const shouldMock = () => !isJackettConfigured(); + +export const getSearchIndexers = (signal: AbortSignal) => + shouldMock() ? Promise.resolve(getMockIndexers()) : listIndexers(signal); + +const describeError = (error: unknown) => { + if (error instanceof DOMException && error.name === "TimeoutError") + return `超时(${INDEXER_TIMEOUT_MS / 1000} 秒)`; + return error instanceof Error ? error.message : "未知错误"; +}; + +/** + * 以 SEARCH_CONCURRENCY 为上限并行查询,每个 Indexer 完成(成功或失败)就 emit 一个事件。 + * signal 中止(用户取消或断开连接)后不再发起新请求,也不再 emit。 + */ +export const runSearch = async ( + indexers: Indexer[], + query: string, + signal: AbortSignal, + emit: (event: SearchEvent) => void +) => { + const queryOne = shouldMock() ? queryMockIndexer : queryIndexer; + const seen = new Set(); + const queue = [...indexers]; + const startedAt = Date.now(); + let resultCount = 0; + let failedCount = 0; + + const worker = async () => { + for (let indexer = queue.shift(); indexer; indexer = queue.shift()) { + if (signal.aborted) return; + const indexerStartedAt = Date.now(); + let results: SearchResult[] = []; + let error: string | undefined; + try { + results = await queryOne( + indexer, + query, + AbortSignal.any([signal, AbortSignal.timeout(INDEXER_TIMEOUT_MS)]) + ); + } catch (caught) { + if (signal.aborted) return; + error = describeError(caught); + } + + // 不同站点常收录同一个种子,同一次搜索里只交付第一次出现的 + const fresh = results.filter((result) => { + if (seen.has(result.id)) return false; + seen.add(result.id); + return true; + }); + const durationMs = Date.now() - indexerStartedAt; + resultCount += fresh.length; + if (error) failedCount += 1; + log[error ? "warn" : "debug"]( + { indexerId: indexer.id, durationMs, resultCount: fresh.length, error }, + "Indexer 搜索完成" + ); + emit({ + type: "indexer", + indexerId: indexer.id, + indexerName: indexer.name, + durationMs, + results: fresh, + error + }); + } + }; + + await Promise.all( + Array.from( + { length: Math.min(SEARCH_CONCURRENCY, indexers.length) }, + worker + ) + ); + if (signal.aborted) { + log.info({ durationMs: Date.now() - startedAt }, "搜索已取消"); + return; + } + log.info( + { + indexerCount: indexers.length, + failedCount, + resultCount, + durationMs: Date.now() - startedAt + }, + "搜索完成" + ); +}; diff --git a/src/server/torbox.ts b/src/server/torbox.ts index 2f90204..7abd070 100644 --- a/src/server/torbox.ts +++ b/src/server/torbox.ts @@ -4,6 +4,7 @@ import { createLogger } from "@/server/logger"; import { getMockLibrary } from "@/server/mock-library"; import { SOURCES, TORBOX_API } from "@/constants/torbox"; import type { + AddTorrentResult, DownloadStatus, LibraryItem, LibraryResponse, @@ -52,9 +53,11 @@ const apiKey = getTorboxApiKey; const log = createLogger("torbox"); -const torboxFetch = async (path: string) => { +const torboxFetch = async (path: string, init: RequestInit = {}) => { const startedAt = Date.now(); + // body 为 FormData 时不能手动设置 Content-Type,fetch 会自动带上 boundary const res = await fetch(TORBOX_API + path, { + ...init, headers: { Authorization: `Bearer ${apiKey()}`, Accept: "application/json" @@ -177,6 +180,33 @@ export const getLibrary = async ({ return { items, errors }; }; +/** 从已校验的 magnet 中取出 info hash(小写) */ +const getMagnetHash = (magnet: string) => + magnet.match(/xt=urn:btih:([0-9a-z]+)/i)?.[1].toLowerCase() ?? ""; + +/** + * 通过 magnet 向 TorBox 添加 torrent。 + * 使用同步的 createtorrent 而非 asynccreatetorrent:后者失败时不会在响应里返回错误,无法即时提示用户。 + * 演示模式下不请求 TorBox,直接返回模拟结果(资源库的模拟数据不会随之变化)。 + */ +export const addMagnet = async (magnet: string): Promise => { + if (isMockMode()) return { id: 0, hash: getMagnetHash(magnet) }; + + const form = new FormData(); + form.set("magnet", magnet); + const data = await torboxFetch("/torrents/createtorrent", { + method: "POST", + body: form + }); + if (typeof data?.torrent_id !== "number") + throw new Error("TorBox 未返回 torrent_id"); + // auth_id 等内部字段不下发给客户端 + return { + id: data.torrent_id, + hash: String(data.hash ?? getMagnetHash(magnet)) + }; +}; + const MOCK_PLAY_ERROR = "演示模式没有可播放的文件,配置 TORBOX_API_KEY 后可播放真实资源"; diff --git a/src/services/search.ts b/src/services/search.ts new file mode 100644 index 0000000..8c06889 --- /dev/null +++ b/src/services/search.ts @@ -0,0 +1,49 @@ +// 搜索 API 请求 +// +// axios 在浏览器里无法边接收边读取响应体,流式接口只能用 fetch; +// 401 与错误提示的处理要和 src/lib/axios.ts 的拦截器保持一致 +import { SEARCH_API } from "@/constants/search"; +import type { ApiResponse } from "@/types/api"; +import type { SearchEvent } from "@/types/search"; + +const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api"; + +/** + * 发起搜索并读取 NDJSON 流,每解析出一行就回调一次。 + * 请求被拒绝(未配置、参数错误、Jackett 不可用等)时抛出带服务端提示的 Error; + * 中止 signal 时抛出 AbortError。 + */ +export const streamSearch = async ( + query: string, + signal: AbortSignal, + onEvent: (event: SearchEvent) => void +) => { + const res = await fetch( + `${API_BASE}${SEARCH_API}?${new URLSearchParams({ q: query })}`, + { signal, cache: "no-store" } + ); + + if (res.status === 401) { + // eslint-disable-next-line @next/next/no-location-assign-relative-destination -- 不在 React 组件内,无法使用 router + window.location.assign("/login"); + throw new Error("登录已失效"); + } + if (!res.ok || !res.body) { + const body: ApiResponse | null = await res.json().catch(() => null); + throw new Error(body?.msg ?? `请求失败(${res.status})`); + } + + const reader = res.body.pipeThrough(new TextDecoderStream()).getReader(); + let buffer = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += value; + // 一次读到的数据可能含多行,也可能只有半行 + for (let end = buffer.indexOf("\n"); end >= 0; end = buffer.indexOf("\n")) { + const line = buffer.slice(0, end).trim(); + buffer = buffer.slice(end + 1); + if (line) onEvent(JSON.parse(line) as SearchEvent); + } + } +}; diff --git a/src/services/torrents.ts b/src/services/torrents.ts new file mode 100644 index 0000000..2603ea6 --- /dev/null +++ b/src/services/torrents.ts @@ -0,0 +1,12 @@ +// torrent 相关 API 请求 +import BaseClientService from "@/lib/axios"; +import type { ApiResponse } from "@/types/api"; +import type { AddTorrentResult } from "@/types/torbox"; + +export const addMagnet = async (magnet: string) => { + const { data } = await BaseClientService.post>( + "/torrents", + { magnet } + ); + return data.data; +}; diff --git a/src/stores/search-store.ts b/src/stores/search-store.ts new file mode 100644 index 0000000..34ce4c5 --- /dev/null +++ b/src/stores/search-store.ts @@ -0,0 +1,149 @@ +// 搜索任务状态 store +// +// 搜索是一个耗时的后台任务(慢站点要等十几秒),弹窗关闭后仍要继续, +// 所以请求和结果都放在 store 里,不跟任何组件的生命周期绑定。 +import { create } from "zustand"; + +import { message } from "@/lib/message"; +import { streamSearch } from "@/services/search"; +import type { SearchEvent, SearchResult } from "@/types/search"; + +export type SearchStatus = + | "idle" + | "searching" + | "done" + | "cancelled" + | "failed"; + +export type SearchFailure = { + indexerId: string; + indexerName: string; + error: string; +}; + +/** 结果加入 TorBox 的进度;没有记录表示还没添加 */ +export type TorrentStatus = "adding" | "added"; + +type SearchState = { + status: SearchStatus; + /** 最近一次提交的关键词 */ + query: string; + /** Indexer 总数,收到 start 事件前为 0 */ + total: number; + /** 已返回(成功或失败)的 Indexer 数 */ + completed: number; + /** 按到达顺序累积,排序交给展示层 */ + results: SearchResult[]; + failures: SearchFailure[]; + /** status 为 failed 时的原因 */ + error: string | null; + /** 按结果 id 记录;不随新搜索清空,同一个种子在弹窗里不会被重复添加 */ + torrentStatus: Record; + open: boolean; + /** 搜索在弹窗关闭期间结束,用户还没看过结果 */ + unseen: boolean; + setOpen: (open: boolean) => void; + /** 记录结果加入 TorBox 的进度;不传 status 表示清除(添加失败后可重试) */ + setTorrentStatus: (id: string, status?: TorrentStatus) => void; + start: (query: string) => Promise; + cancel: () => void; +}; + +const initialTask: Pick< + SearchState, + "status" | "query" | "total" | "completed" | "results" | "failures" | "error" +> = { + status: "idle", + query: "", + total: 0, + completed: 0, + results: [], + failures: [], + error: null +}; + +/** 当前搜索的中止器;换成新搜索或取消后,旧搜索的回调据此判断自己是否已过期 */ +let controller: AbortController | null = null; + +export const useSearchStore = create()((set, get) => { + /** 结束一次搜索;弹窗已关闭时用 toast 与按钮高亮告知 */ + const settle = (patch: Partial, notify: () => void) => { + const hidden = !get().open; + set({ ...patch, unseen: hidden }); + if (hidden) notify(); + }; + + const handleEvent = (event: SearchEvent) => { + if (event.type === "start") set({ total: event.total }); + else if (event.type === "indexer") + set((state) => ({ + completed: state.completed + 1, + results: event.results.length + ? state.results.concat(event.results) + : state.results, + failures: event.error + ? [ + ...state.failures, + { + indexerId: event.indexerId, + indexerName: event.indexerName, + error: event.error + } + ] + : state.failures + })); + else + settle({ status: "done" }, () => { + const { query, results } = get(); + message.success(`「${query}」搜索完成,共 ${results.length} 条结果`); + }); + }; + + const fail = (error: string) => + settle({ status: "failed", error }, () => + message.error(`「${get().query}」搜索失败:${error}`) + ); + + return { + ...initialTask, + torrentStatus: {}, + open: false, + unseen: false, + + setOpen: (open) => set(open ? { open, unseen: false } : { open }), + + setTorrentStatus: (id, status) => + set((state) => { + const next = { ...state.torrentStatus }; + if (status) next[id] = status; + else delete next[id]; + return { torrentStatus: next }; + }), + + start: async (query) => { + controller?.abort(); + const current = new AbortController(); + controller = current; + set({ ...initialTask, status: "searching", query, unseen: false }); + + try { + await streamSearch(query, current.signal, (event) => { + if (controller === current) handleEvent(event); + }); + // 流正常结束却没收到 done:连接被中途切断 + if (controller === current && get().status === "searching") + fail("连接中断,结果可能不完整"); + } catch (error) { + if (controller !== current) return; + fail(error instanceof Error ? error.message : "未知错误"); + } + }, + + cancel: () => { + if (get().status !== "searching") return; + controller?.abort(); + controller = null; + set({ status: "cancelled" }); + } + }; +}); diff --git a/src/stores/search-view-store.ts b/src/stores/search-view-store.ts new file mode 100644 index 0000000..40f3d77 --- /dev/null +++ b/src/stores/search-view-store.ts @@ -0,0 +1,32 @@ +// 搜索结果的筛选与排序状态 store +import { create } from "zustand"; + +import { SEARCH_SORT_OPTIONS } from "@/constants/search"; +import type { SearchSortKey, SortOrder } from "@/types/search"; + +type SearchViewState = { + /** 在已有结果里按标题筛选,空格分隔多个词(全部命中才显示) */ + keyword: string; + sortKey: SearchSortKey; + order: SortOrder; + setKeyword: (keyword: string) => void; + /** 切换排序方式,方向回到该方式的默认方向 */ + setSortKey: (sortKey: SearchSortKey) => void; + setOrder: (order: SortOrder) => void; +}; + +/** 结果区的视图设置(工具栏与列表共享),不持久化;与搜索任务本身(search-store)分开 */ +export const useSearchViewStore = create()((set) => ({ + keyword: "", + sortKey: "seeders", + order: "desc", + setKeyword: (keyword) => set({ keyword }), + setSortKey: (sortKey) => + set({ + sortKey, + order: + SEARCH_SORT_OPTIONS.find((option) => option.value === sortKey) + ?.defaultOrder ?? "desc" + }), + setOrder: (order) => set({ order }) +})); diff --git a/src/types/search.ts b/src/types/search.ts new file mode 100644 index 0000000..c0d89c4 --- /dev/null +++ b/src/types/search.ts @@ -0,0 +1,40 @@ +// 搜索类型:服务端与浏览器共用,只含可下发给浏览器的字段(不含 Jackett 的下载链接,其中带有 API Key) + +export type SearchResult = { + /** 去重键:优先 infohash,否则 Indexer + 站点条目标识 */ + id: string; + title: string; + indexerId: string; + indexerName: string; + /** 字节数;站点未提供时为 null */ + size: number | null; + /** 做种者 */ + seeders: number | null; + /** 下载者;Jackett 只给「做种+下载」的总数,这里是总数减做种数 */ + leechers: number | null; + /** 发布时间(ISO);站点未提供时为 null */ + publishedAt: string | null; + /** 站点详情页 */ + detailsUrl: string | null; + /** 站点未直接给出 magnet 时为 null(Jackett 需要再请求一次才能拿到) */ + magnet: string | null; +}; + +export type SearchSortKey = "name" | "seeders" | "leechers" | "size" | "date"; + +export type SortOrder = "asc" | "desc"; + +/** NDJSON 流中的一行:start → 若干 indexer → done */ +export type SearchEvent = + | { type: "start"; total: number } + | { + type: "indexer"; + indexerId: string; + indexerName: string; + durationMs: number; + /** 去掉了本次搜索中已出现过的重复结果 */ + results: SearchResult[]; + /** 有值表示该 Indexer 失败,results 为空 */ + error?: string; + } + | { type: "done" }; diff --git a/src/types/torbox.ts b/src/types/torbox.ts index e050441..ae448f1 100644 --- a/src/types/torbox.ts +++ b/src/types/torbox.ts @@ -36,6 +36,13 @@ export type LibraryItem = { files: LibraryFile[]; }; +/** 添加 magnet 成功后的结果 */ +export type AddTorrentResult = { + /** TorBox 的 torrent_id,与资源库中 torrents 条目的 id 一致 */ + id: number; + hash: string; +}; + export type LibraryResponse = { items: LibraryItem[]; /** 拉取失败的来源及原因 */ diff --git a/src/utils/date.ts b/src/utils/date.ts index 2295f67..5844cc0 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -41,3 +41,29 @@ export const formatDateTime = (iso: string) => dateStyle: "short", timeStyle: "short" }); + +/** 本地化日期,如 2026/10/05 */ +export const formatDate = (iso: string) => + new Date(iso).toLocaleDateString("zh-CN"); + +/** 相对日期按自然日算:7 天内为「今天 / 昨天 / n 天前」,更早或晚于 now 显示 2026年10月05日 */ +const RELATIVE_DAYS = 7; + +export const formatRelativeDate = (iso: string, now: number) => { + const time = toTimestamp(iso); + if (time === null) return ""; + + const date = new Date(time); + const startOfDay = (value: Date) => + new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); + // 用本地自然日相减,不会因为夏令时之类的偏移算错天数 + const days = Math.round((startOfDay(new Date(now)) - startOfDay(date)) / DAY); + + if (days === 0) return "今天"; + if (days === 1) return "昨天"; + if (days > 1 && days < RELATIVE_DAYS) return `${days} 天前`; + + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${date.getFullYear()}年${month}月${day}日`; +}; diff --git a/src/utils/magnet.ts b/src/utils/magnet.ts new file mode 100644 index 0000000..2107a68 --- /dev/null +++ b/src/utils/magnet.ts @@ -0,0 +1,8 @@ +// 磁力链接校验(纯函数,前后端共用) + +/** btih 为 40 位十六进制或 32 位 base32 */ +const BTIH = /[?&]xt=urn:btih:(?:[0-9a-f]{40}|[a-z2-7]{32})(?:&|$)/i; + +/** 是否为包含合法 info hash 的 magnet 链接 */ +export const isMagnetLink = (value: string) => + /^magnet:\?/i.test(value) && BTIH.test(value);