feat: 新增torbox下载接口,引入Jackett做资源搜索
torbox-admin / deploy (push) Successful in 2m15s

This commit is contained in:
2026-10-06 05:27:46 -07:00
parent 857890fdf6
commit 1f1e7f9bca
36 changed files with 1986 additions and 7 deletions
+4
View File
@@ -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=
+1
View File
@@ -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`。
### 长列表(虚拟滚动)
+1
View File
@@ -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",
+58
View File
@@ -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):
@@ -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 (
<>
<Button variant="ghost" onClick={() => setOpen(true)}>
<MagnetIcon data-icon="inline-start" />
{/* 手机宽度下只显示图标,文字留给读屏 */}
<span className="sr-only sm:not-sr-only">添加磁力</span>
</Button>
<AddMagnetDialog open={open} onOpenChange={setOpen} />
</>
);
};
@@ -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 (
<>
<form
id={FORM_ID}
onSubmit={(event) => {
event.preventDefault();
if (!magnet || invalid) return;
// 失败提示由 axios 拦截器统一处理,弹窗保持打开以便修改后重试
mutate(magnet, { onSuccess: onAdded });
}}
>
<FieldGroup>
<Field data-invalid={invalid || undefined}>
<FieldLabel htmlFor="add-magnet-input">磁力链接</FieldLabel>
<InputGroup>
<InputGroupTextarea
id="add-magnet-input"
name="magnet"
value={draft}
onChange={(event) => 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"
/>
</InputGroup>
{invalid ? <FieldError>请输入有效的磁力链接</FieldError> : null}
</Field>
</FieldGroup>
</form>
<DialogFooter>
<Button
type="submit"
form={FORM_ID}
disabled={isPending || !magnet || invalid}
>
{isPending ? (
<Spinner data-icon="inline-start" />
) : (
<CloudDownloadIcon data-icon="inline-start" />
)}
添加到 TorBox
</Button>
</DialogFooter>
</>
);
};
type AddMagnetDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
export const AddMagnetDialog = ({
open,
onOpenChange
}: AddMagnetDialogProps) => (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>添加磁力链接</DialogTitle>
</DialogHeader>
<AddMagnetForm onAdded={() => onOpenChange(false)} />
</DialogContent>
</Dialog>
);
+7 -1
View File
@@ -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) => (
<header className="shrink-0 border-b">
<div className="mx-auto flex h-14 w-full max-w-7xl items-center gap-3 px-4 md:px-6">
<BrandMark />
@@ -27,6 +31,8 @@ export const AppHeader = ({ token, mock }: AppHeaderProps) => (
</Badge>
)}
<div className="ml-auto flex items-center gap-1">
<AddMagnetButton />
{searchEnabled && <SearchButton />}
<PlaylistMenu token={token} />
<Separator orientation="vertical" className="mx-1 my-4" />
<LogoutButton />
@@ -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 (
<>
<Button
variant="ghost"
className={cn(finished && "text-primary", failed && "text-destructive")}
onClick={() => setOpen(true)}
>
{searching ? (
<Spinner data-icon="inline-start" />
) : finished ? (
<CheckIcon data-icon="inline-start" />
) : failed ? (
<TriangleAlertIcon data-icon="inline-start" />
) : (
<SearchIcon data-icon="inline-start" />
)}
{/* 手机宽度下只显示图标,文字留给读屏 */}
<span className="sr-only sm:not-sr-only">
{searching
? "搜索中"
: finished
? "搜索完成"
: failed
? "搜索失败"
: "搜索"}
</span>
{searching && total > 0 && (
<span className="font-mono text-xs text-muted-foreground tabular-nums">
{completed}/{total}
</span>
)}
</Button>
<SearchDialog />
</>
);
};
+256
View File
@@ -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 (
<form
onSubmit={(event) => {
event.preventDefault();
if (!keyword) return;
// 新结果与旧的筛选词无关,清掉避免一上来就是空列表
useSearchViewStore.getState().setKeyword("");
void start(keyword);
}}
>
<InputGroup>
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput
value={draft}
onChange={(event) => setDraft(event.target.value)}
maxLength={MAX_QUERY_LENGTH}
placeholder="输入关键词,如片名"
aria-label="搜索关键词"
autoComplete="off"
/>
<InputGroupAddon align="inline-end">
{status === "searching" ? (
<InputGroupButton variant="outline" onClick={cancel}>
取消
</InputGroupButton>
) : (
<InputGroupButton
type="submit"
variant="default"
disabled={!keyword}
>
搜索
</InputGroupButton>
)}
</InputGroupAddon>
</InputGroup>
</form>
);
};
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 (
<div className="flex flex-col gap-3">
<Progress
// 收到 start 事件前不知道总数,显示为不确定进度
value={total ? (completed / total) * 100 : null}
aria-label="搜索进度"
>
<ProgressLabel>{progressLabel(status, resultCount)}</ProgressLabel>
<ProgressValue>
{() => (total ? `${completed} / ${total} 个站点` : "")}
</ProgressValue>
</Progress>
{status === "failed" && error && (
<Alert variant="destructive">
<TriangleAlertIcon />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{failures.length > 0 && (
<Collapsible>
<CollapsibleTrigger className="group flex items-center gap-1 text-xs text-muted-foreground outline-none hover:text-foreground focus-visible:text-foreground">
{failures.length} 个站点没有返回结果
<ChevronDownIcon className="size-3.5 transition-transform group-aria-expanded:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent>
<ul className="mt-2 flex max-h-24 flex-col gap-1 overflow-y-auto font-mono text-xs text-muted-foreground">
{failures.map((failure) => (
<li
key={failure.indexerId}
className="truncate"
title={failure.error}
>
{failure.indexerName} — {failure.error}
</li>
))}
</ul>
</CollapsibleContent>
</Collapsible>
)}
</div>
);
};
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 (
<div className="flex flex-col gap-2">
<SearchResultToolbar total={results.length} shown={items.length} />
{items.length > 0 ? (
<SearchResultList
items={items}
terms={terms}
resetScrollKey={`${sortKey}:${order}:${terms.join(" ")}`}
className={RESULT_HEIGHT}
/>
) : (
<Empty className={RESULT_HEIGHT}>
<EmptyHeader>
<EmptyMedia variant="icon">
<ListFilterIcon />
</EmptyMedia>
<EmptyTitle>没有匹配的结果</EmptyTitle>
<EmptyDescription>
共 {results.length} 条结果中没有同时包含这些词的
</EmptyDescription>
</EmptyHeader>
<Button variant="outline" size="sm" onClick={() => setKeyword("")}>
清除筛选
</Button>
</Empty>
)}
</div>
);
if (status === "searching")
return (
<div
className={cn("flex flex-col gap-2", RESULT_HEIGHT)}
aria-busy="true"
aria-label="正在等待站点返回"
>
{Array.from({ length: 10 }, (_, index) => (
<Skeleton key={index} className="h-14 shrink-0" />
))}
</div>
);
const idle = status === "idle";
return (
<Empty className={RESULT_HEIGHT}>
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchIcon />
</EmptyMedia>
<EmptyTitle>{idle ? "输入关键词开始搜索" : "没有找到结果"}</EmptyTitle>
<EmptyDescription>{idle ? "" : "换个关键词试试"}</EmptyDescription>
</EmptyHeader>
</Empty>
);
};
export const SearchDialog = () => {
const open = useSearchStore((state) => state.open);
const setOpen = useSearchStore((state) => state.setOpen);
return (
<Dialog open={open} onOpenChange={setOpen}>
{/* grid-cols-1:轨道可被压缩,内容再宽也不会把弹窗撑出屏幕 */}
<DialogContent className="grid-cols-1 sm:max-w-5xl">
<DialogHeader>
<DialogTitle>搜索资源</DialogTitle>
</DialogHeader>
<SearchForm />
<SearchProgress />
<SearchResults />
</DialogContent>
</Dialog>
);
};
@@ -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) => (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label={label}
className={className}
disabled={pending || disabled}
onClick={onClick}
{...(href
? {
nativeButton: false,
render: (
<a href={href} target="_blank" rel="noopener noreferrer" />
)
}
: {})}
/>
}
>
{pending ? <Spinner /> : <Icon />}
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
/** 每行都能跳转到站点;有 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 && (
<>
<RowAction
label="复制 magnet 链接"
icon={CopyIcon}
onClick={() => copy(magnet, "已复制 magnet 链接")}
/>
<RowAction
label={
status === "added"
? "已添加到 TorBox"
: status === "adding"
? "正在添加到 TorBox"
: "添加到 TorBox"
}
icon={status === "added" ? CheckIcon : CloudDownloadIcon}
pending={status === "adding"}
disabled={status === "added"}
className={cn(status === "added" && "text-primary")}
onClick={() => addToTorbox(magnet)}
/>
</>
)}
{detailsUrl && (
<RowAction
label="打开站点页面"
icon={ExternalLinkIcon}
href={detailsUrl}
/>
)}
</>
);
};
/** 做种 / 下载数:只显示图标与数字,名称放在 title 与读屏文字里 */
const Stat = ({
label,
icon: Icon,
value
}: {
label: string;
icon: LucideIcon;
value: number;
}) => (
<span className="inline-flex items-center gap-1" title={label}>
<Icon className="size-3.5" aria-hidden />
<span className="sr-only">{label}</span>
{formatCount(value)}
</span>
);
const ResultRow = memo(function ResultRow({
result,
terms,
now
}: {
result: SearchResult;
terms: readonly string[];
now: number;
}) {
const { publishedAt, size, seeders, leechers } = result;
return (
<div className="flex flex-col gap-2 border-b px-1 py-3">
<div className="flex items-center justify-between gap-3">
{/* 站点没给发布时间时留空,来源仍靠右 */}
<span className="font-mono text-xs text-muted-foreground tabular-nums">
{publishedAt ? formatRelativeDate(publishedAt, now) : null}
</span>
<Badge variant="outline" className="min-w-0">
<span className="truncate">{result.indexerName}</span>
</Badge>
</div>
{/* 名称不省略;没有空格的超长名称也要能在容器内断行 */}
<span className="text-sm font-medium wrap-anywhere">
<HighlightText text={result.title} terms={terms} />
</span>
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3 font-mono text-xs text-muted-foreground tabular-nums">
{size != null && <span>{formatBytes(size)}</span>}
{seeders != null && (
<Stat label="做种" icon={ArrowUpIcon} value={seeders} />
)}
{leechers != null && (
<Stat label="下载" icon={ArrowDownIcon} value={leechers} />
)}
</div>
{/* 窄屏换到下一行,靠右 */}
<div className="flex items-center gap-1 self-end sm:self-auto">
<ResultActions result={result} />
</div>
</div>
</div>
);
});
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 (
<VirtualList
items={items}
getItemKey={(result) => result.id}
estimateSize={ESTIMATED_ROW_HEIGHT}
renderItem={(result) => (
<ResultRow result={result} terms={terms} now={now} />
)}
resetScrollKey={resetScrollKey}
className={className}
/>
);
};
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
className="shrink-0"
aria-label={`排序:${label},${orderLabel}`}
/>
}
>
<ArrowDownUpIcon data-icon="inline-start" />
{label}
{order === "asc" ? (
<ArrowUpIcon data-icon="inline-end" />
) : (
<ArrowDownIcon data-icon="inline-end" />
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuGroup>
<DropdownMenuLabel>排序方式</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={sortKey}
onValueChange={(value: SearchSortKey) => setSortKey(value)}
>
{SEARCH_SORT_OPTIONS.map((option) => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
closeOnClick
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>顺序</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={order}
onValueChange={(value: SortOrder) => setOrder(value)}
>
{ORDER_OPTIONS.map((option) => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
closeOnClick
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
};
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 (
<div className="flex items-center gap-2">
<InputGroup>
<InputGroupAddon>
<ListFilterIcon />
</InputGroupAddon>
<InputGroupInput
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="在结果中筛选,空格分隔多个词"
aria-label="在结果中筛选"
autoComplete="off"
/>
{keyword && (
<InputGroupAddon align="inline-end">
<InputGroupText className="font-mono text-xs tabular-nums">
{shown} / {total}
</InputGroupText>
<InputGroupButton
size="icon-xs"
aria-label="清除筛选"
onClick={() => setKeyword("")}
>
<XIcon />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
<SortMenu />
</div>
);
};
+6 -1
View File
@@ -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 (
<div className="flex h-dvh flex-col">
<AppHeader token={token} mock={isMockMode()} />
<AppHeader
token={token}
mock={isMockMode()}
searchEnabled={isSearchAvailable()}
/>
<main className="flex min-h-0 flex-1 flex-col">{children}</main>
</div>
);
+50
View File
@@ -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<SearchEvent>(req, async (send, signal) => {
send({ type: "start", total: indexers.length });
await runSearch(indexers, query, signal, send);
send({ type: "done" });
});
});
+35
View File
@@ -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"
);
}
});
+38
View File
@@ -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:命中词跨行断开时,每一段都保留完整的圆角与内边距
<mark
key={index}
className="rounded-sm bg-primary px-0.5 text-primary-foreground [box-decoration-break:clone]"
>
{part}
</mark>
) : (
<Fragment key={index}>{part}</Fragment>
)
);
};
+2
View File
@@ -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)",
+3 -1
View File
@@ -29,7 +29,9 @@ function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) {
<ToastPrimitive.Viewport
data-slot="toast-viewport"
className={cn(
"pointer-events-none fixed top-4 left-1/2 z-50 w-full max-w-sm -translate-x-1/2 outline-none",
// 层级高于 Dialog、DropdownMenu、Tooltip 等浮层(z-50):它们在打开时才挂到 body 末尾,
// 层级相同会盖住通知
"pointer-events-none fixed top-4 left-1/2 z-100 w-full max-w-sm -translate-x-1/2 outline-none",
className
)}
{...props}
+69
View File
@@ -0,0 +1,69 @@
// 通用虚拟列表:行高不固定,渲染后自动测量;只渲染可见行(及上下各 overscan 行)
//
// 滚动容器就是组件自身,需要通过 className 给它确定的高度(或放进 flex-1 min-h-0 的容器)。
// 不使用定位:上下各放一个占位块撑出未渲染部分的高度,可见行保持在正常文档流里。
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { cn } from "@/lib/utils";
type VirtualListProps<T> = {
items: readonly T[];
/** 稳定的行标识;重新排序后已测量的行高会跟着行走 */
getItemKey: (item: T) => string | number;
/** 未测量前的估计行高(px),越接近实际值滚动条越稳 */
estimateSize: number;
renderItem: (item: T) => ReactNode;
overscan?: number;
/** 值变化时滚回顶部,如筛选、排序条件改变;流式追加数据不应改变它 */
resetScrollKey?: string;
className?: string;
};
export const VirtualList = <T,>({
items,
getItemKey,
estimateSize,
renderItem,
overscan = 8,
resetScrollKey,
className
}: VirtualListProps<T>) => {
const scrollRef = useRef<HTMLDivElement>(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 (
<div ref={scrollRef} className={cn("overflow-y-auto", className)}>
<div style={{ height: top }} />
{rows.map((row) => (
// measureElement 读取 data-index 并监听尺寸变化
<div
key={row.key}
data-index={row.index}
ref={virtualizer.measureElement}
>
{renderItem(items[row.index])}
</div>
))}
<div style={{ height: bottom }} />
</div>
);
};
+28
View File
@@ -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" }
];
+3
View File
@@ -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",
+57
View File
@@ -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<SearchResult> => {
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 };
};
+36
View File
@@ -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?.()
});
};
+25 -3
View File
@@ -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));
+149
View File
@@ -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 + <error>,或 4xx 带 <error>) */
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<string, any>; // 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<Indexer[]> } | 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<string, string>(
(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<SearchResult[]> => {
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) ?? []);
};
+88
View File
@@ -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<SearchResult[]> =>
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 }
);
});
+42
View File
@@ -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 = <T>(
req: Request,
run: (send: (event: T) => void, signal: AbortSignal) => Promise<void>
) => {
const abort = new AbortController();
req.signal.addEventListener("abort", () => abort.abort(), { once: true });
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
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"
}
});
};
+103
View File
@@ -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<string>();
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
},
"搜索完成"
);
};
+31 -1
View File
@@ -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<AddTorrentResult> => {
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 后可播放真实资源";
+49
View File
@@ -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);
}
}
};
+12
View File
@@ -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<ApiResponse<AddTorrentResult>>(
"/torrents",
{ magnet }
);
return data.data;
};
+149
View File
@@ -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<string, TorrentStatus>;
open: boolean;
/** 搜索在弹窗关闭期间结束,用户还没看过结果 */
unseen: boolean;
setOpen: (open: boolean) => void;
/** 记录结果加入 TorBox 的进度;不传 status 表示清除(添加失败后可重试) */
setTorrentStatus: (id: string, status?: TorrentStatus) => void;
start: (query: string) => Promise<void>;
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<SearchState>()((set, get) => {
/** 结束一次搜索;弹窗已关闭时用 toast 与按钮高亮告知 */
const settle = (patch: Partial<SearchState>, 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" });
}
};
});
+32
View File
@@ -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<SearchViewState>()((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 })
}));
+40
View File
@@ -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" };
+7
View File
@@ -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[];
/** 拉取失败的来源及原因 */
+26
View File
@@ -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}日`;
};
+8
View File
@@ -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);