feat: 增加接口限流
torbox-admin / deploy (push) Successful in 2m29s

This commit is contained in:
2026-10-06 20:32:58 -07:00
parent 77f7c9b0ea
commit 54cecbf23f
33 changed files with 1127 additions and 103 deletions
+11
View File
@@ -0,0 +1,11 @@
# .gitignore 里的文件 Prettier 默认已跳过,这里只列入库但不该格式化的
# 工具生成的锁文件
pnpm-lock.yaml
skills-lock.json
# shadcn CLI 生成的基础组件,保持生成时的样式,重新 add 时不产生格式差异
src/components/ui
# 从 Video.js 引入的播放器皮肤,保持上游样式便于对照
src/components/videojs
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"tabWidth": 2,
"singleQuote": false,
"printWidth": 80,
"trailingComma": "none"
}
+2
View File
@@ -28,6 +28,8 @@ WORKDIR /app
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
COPY --from=builder --chown=node:node /app/public ./public
# 数据库迁移文件:启动时自动应用(src/server/db/migrate.ts)
COPY --from=builder --chown=node:node /app/drizzle ./drizzle
# SQLite 数据库目录:生产环境通过 DB_FILE_NAME 指向挂载卷;预先创建并授权,未挂载时也能启动
RUN mkdir -p /app/data && chown node:node /app/data
@@ -0,0 +1,18 @@
CREATE TABLE `torbox_download_urls` (
`key` text PRIMARY KEY,
`url` text NOT NULL,
`expires_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `torbox_rate_hits` (
`id` integer PRIMARY KEY AUTOINCREMENT,
`rule` text NOT NULL,
`at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `torbox_rate_pauses` (
`rule` text PRIMARY KEY,
`until` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `torbox_rate_hits_rule_at_idx` ON `torbox_rate_hits` (`rule`,`at`);
@@ -0,0 +1,148 @@
{
"version": "7",
"dialect": "sqlite",
"id": "2ad8ae3a-f804-4eff-ba8d-b9deeafe2dd9",
"prevIds": [
"00000000-0000-0000-0000-000000000000"
],
"ddl": [
{
"name": "torbox_download_urls",
"entityType": "tables"
},
{
"name": "torbox_rate_hits",
"entityType": "tables"
},
{
"name": "torbox_rate_pauses",
"entityType": "tables"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "torbox_download_urls"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "url",
"entityType": "columns",
"table": "torbox_download_urls"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "expires_at",
"entityType": "columns",
"table": "torbox_download_urls"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "torbox_rate_hits"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "rule",
"entityType": "columns",
"table": "torbox_rate_hits"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "at",
"entityType": "columns",
"table": "torbox_rate_hits"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "rule",
"entityType": "columns",
"table": "torbox_rate_pauses"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "until",
"entityType": "columns",
"table": "torbox_rate_pauses"
},
{
"columns": [
"key"
],
"nameExplicit": false,
"name": "torbox_download_urls_pk",
"table": "torbox_download_urls",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "torbox_rate_hits_pk",
"table": "torbox_rate_hits",
"entityType": "pks"
},
{
"columns": [
"rule"
],
"nameExplicit": false,
"name": "torbox_rate_pauses_pk",
"table": "torbox_rate_pauses",
"entityType": "pks"
},
{
"columns": [
{
"value": "rule",
"isExpression": false
},
{
"value": "at",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "torbox_rate_hits_rule_at_idx",
"entityType": "indexes",
"table": "torbox_rate_hits"
}
],
"renames": []
}
+4 -1
View File
@@ -7,6 +7,8 @@
"build": "next build",
"start": "next start -p 3200",
"lint": "eslint",
"format": "prettier --write .",
"format:check": "prettier --check .",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
@@ -44,9 +46,10 @@
"eslint": "^9",
"eslint-config-next": "16.3.5",
"eslint-plugin-drizzle": "1.0.0-rc.4",
"prettier": "^3.9.9",
"shadcn": "^4.21.0",
"tailwindcss": "^4",
"typescript": "^5.9.3"
},
"packageManager": "pnpm@10.34.5"
}
}
+10
View File
@@ -99,6 +99,9 @@ importers:
eslint-plugin-drizzle:
specifier: 1.0.0-rc.4
version: 1.0.0-rc.4(eslint@9.39.5(jiti@2.7.0))
prettier:
specifier: ^3.9.9
version: 3.9.9
shadcn:
specifier: ^4.21.0
version: 4.21.0(typescript@5.9.3)
@@ -3090,6 +3093,11 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
prettier@3.9.9:
resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==}
engines: {node: '>=14'}
hasBin: true
pretty-ms@9.3.1:
resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==}
engines: {node: '>=18'}
@@ -6649,6 +6657,8 @@ snapshots:
prelude-ls@1.2.1: {}
prettier@3.9.9: {}
pretty-ms@9.3.1:
dependencies:
parse-ms: 4.0.0
+22 -8
View File
@@ -13,10 +13,16 @@ import {
import { Spinner } from "@/components/ui/spinner";
import { IMAGE_LOADING } from "@/constants/motion";
import { useCountdown } from "@/hooks/use-countdown";
import { useLimitRetry } from "@/hooks/use-limit-retry";
/** 加载中的覆盖层:翠绿点阵上有一道光带从左扫到右,中间是转圈与文字 */
const LoadingOverlay = () => {
/**
* 加载中的覆盖层:翠绿点阵上有一道光带从左扫到右,中间是转圈与文字。
* 因限流等待时显示倒计时,额度恢复后自动重新加载
*/
const LoadingOverlay = ({ limitedUntil }: { limitedUntil: number | null }) => {
const reduced = useReducedMotion();
const seconds = useCountdown(limitedUntil);
const progress = useMotionValue(0);
// 光带在 -40% ~ 140% 之间移动,起止都在封面之外
const maskImage = useTransform(progress, (value) => {
@@ -46,7 +52,7 @@ const LoadingOverlay = () => {
<span className="flex items-center justify-center">
<span className="flex items-center gap-1.5 rounded-full bg-background/70 py-1 pr-2.5 pl-2 font-mono text-xs text-primary backdrop-blur-sm">
<Spinner className="size-3.5" />
加载中
{limitedUntil === null ? "加载中" : `限流中 ${seconds}s`}
</span>
</span>
</motion.span>
@@ -60,19 +66,25 @@ type ImageCoverProps = {
onError: () => void;
};
/** 加载失败后自动重试的间隔(毫秒),用完仍失败才通知父组件;重试期间继续显示加载状态 */
/**
* 加载失败后自动重试的间隔(毫秒),用完仍失败才通知父组件;重试期间继续显示加载状态。
* 因 TorBox 限流失败的不占重试次数,等额度恢复后再加载
*/
const RETRY_DELAYS = [800, 2000, 4000];
/** 图片封面:点击打开预览灯箱;图片就绪前显示加载状态,就绪后淡入;失败时自动重试 */
export const ImageCover = ({ src, alt, onOpen, onError }: ImageCoverProps) => {
const [loaded, setLoaded] = useState(false);
// 作为 img 的 key:换一个值即重新挂载并重新发起请求
// 普通失败的重试次数;与限流后的重新加载次数一起作为 img 的 key,变化即重新挂载并重新发起请求
const [attempt, setAttempt] = useState(0);
const retryTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
const limit = useLimitRetry("file");
useEffect(() => () => clearTimeout(retryTimer.current), []);
const handleError = () => {
const handleError = async () => {
setLoaded(false);
if (await limit.onError()) return;
const delay = RETRY_DELAYS[attempt];
if (delay === undefined) return onError();
retryTimer.current = setTimeout(() => setAttempt(attempt + 1), delay);
@@ -86,10 +98,12 @@ export const ImageCover = ({ src, alt, onOpen, onError }: ImageCoverProps) => {
aria-busy={!loaded}
onClick={onOpen}
>
<AnimatePresence>{!loaded && <LoadingOverlay />}</AnimatePresence>
<AnimatePresence>
{!loaded && <LoadingOverlay limitedUntil={limit.wait?.until ?? null} />}
</AnimatePresence>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
key={attempt}
key={`${attempt}-${limit.reloadKey}`}
src={src}
alt={alt}
loading="lazy"
+2 -8
View File
@@ -18,12 +18,9 @@ import {
type VirtualItem,
type Virtualizer
} from "@tanstack/react-virtual";
import { TriangleAlertIcon } from "lucide-react";
import { Reticle } from "@/components/reticle";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { RETICLE } from "@/constants/motion";
import { SOURCE_LABELS } from "@/constants/torbox";
import { useElementWidth } from "@/hooks/use-element-width";
import { useFollowHighlight } from "@/hooks/use-follow-highlight";
import { useGridKeyboardNav } from "@/hooks/use-grid-keyboard-nav";
@@ -61,6 +58,7 @@ import {
type RowPiece
} from "./library-rows";
import { LibraryVideoDialog } from "./library-video-dialog";
import { SourceNotice } from "./source-notice";
/** 列表顶部留白;首个分组标题自带 8px 上留白,合计 20px */
const PADDING_START = 12;
@@ -181,11 +179,7 @@ const RowView = memo(function RowView({
return (
<div data-index={index} ref={ref} className={rowClassName(row, stuck)}>
{row.type === "notice" && (
<Alert variant="destructive">
<TriangleAlertIcon />
<AlertTitle>{SOURCE_LABELS[row.source]} 拉取失败</AlertTitle>
<AlertDescription>{row.detail}</AlertDescription>
</Alert>
<SourceNotice source={row.source} detail={row.detail} />
)}
{row.type === "header" && (
// 吸附时 stuck-divider 给这一层显示底边线
@@ -14,15 +14,19 @@ import {
DialogTitle
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import {
importVideoPlayer,
preloadVideoPlayer
} from "@/components/video-player-loader";
import { BROWSER_VIDEO_EXT } from "@/constants/torbox";
import { useCloseOnBack } from "@/hooks/use-close-on-back";
import { useCountdown } from "@/hooks/use-countdown";
import { useLimitRetry, type LimitWait } from "@/hooks/use-limit-retry";
import type { LibraryItemView } from "@/hooks/use-library";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useOrigin } from "@/hooks/use-origin";
import { cn } from "@/lib/utils";
import { useVideoDialogStore } from "@/stores/video-dialog-store";
import { formatBytes } from "@/utils/format";
import { buildHlsUrl, buildStreamUrl, toPotPlayerUrl } from "@/utils/media";
@@ -43,6 +47,71 @@ const VideoPlayer = dynamic(importVideoPlayer, {
loading: () => <Skeleton className={PLAYER_CLASS} />
});
const UNSUPPORTED_DESCRIPTION =
"当前浏览器暂不支持播放该视频,请使用 PotPlayer 等本地播放器播放,或下载视频文件后查看。";
type LimitAwareVideoPlayerProps = {
src: string;
hls: boolean;
title: string;
onEnded?: () => void;
onClose: () => void;
};
/** 等待额度恢复时代替播放器,与播放器加载占位同尺寸 */
const LimitWaiting = ({
wait,
onClose
}: {
wait: LimitWait;
onClose: () => void;
}) => {
const seconds = useCountdown(wait.until);
return (
<div
role="status"
className={cn(
PLAYER_CLASS,
"flex flex-col items-center justify-center gap-3 rounded-md bg-muted p-6 text-center"
)}
>
<Spinner className="size-6 text-primary" />
<p className="text-sm text-muted-foreground">
{wait.label}过于频繁,{seconds} 秒后自动重新加载
</p>
<Button variant="outline" size="sm" onClick={onClose}>
关闭
</Button>
</div>
);
};
/**
* 播放出错时先查是否因 TorBox 限流:是则换成倒计时,额度恢复后重新挂载播放器自动重播。
* 本地限流的 429 返回得很快,播放器自己的错误弹窗可能来不及捕获,所以倒计时不放在弹窗里。
* 调用方以地址作为 key,换视频时等待随之取消
*/
const LimitAwareVideoPlayer = ({
hls,
...props
}: LimitAwareVideoPlayerProps) => {
const limit = useLimitRetry(hls ? "hls" : "file");
if (limit.wait)
return <LimitWaiting wait={limit.wait} onClose={props.onClose} />;
return (
<VideoPlayer
key={limit.reloadKey}
{...props}
hls={hls}
autoPlay
autoFocus
onError={limit.onError}
errorDescription={UNSUPPORTED_DESCRIPTION}
className={PLAYER_CLASS}
/>
);
};
type LibraryVideoDialogProps = {
items: LibraryItemView[];
token: string;
@@ -147,17 +216,13 @@ export const LibraryVideoDialog = ({
{/* 主列:视频固定在顶部,下方是标题信息,窄屏连同播放列表 */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className="shrink-0">
<VideoPlayer
<LimitAwareVideoPlayer
key={hlsUrl ?? streamUrl}
src={hlsUrl ?? streamUrl}
hls={hlsUrl !== null}
title={file.displayName}
autoPlay
autoFocus
onEnded={nextFile && (() => selectFile(nextFile))}
onClose={onClose}
errorDescription="当前浏览器暂不支持播放该视频,请使用 PotPlayer 等本地播放器播放,或下载视频文件后查看。"
className={PLAYER_CLASS}
/>
</div>
{/*
+2 -6
View File
@@ -19,7 +19,6 @@ import {
EmptyMedia,
EmptyTitle
} from "@/components/ui/empty";
import { SOURCE_LABELS } from "@/constants/torbox";
import { useFilteredLibrary, useRefreshLibrary } from "@/hooks/use-library";
import { useLibraryFilterStore } from "@/stores/library-filter-store";
import {
@@ -32,6 +31,7 @@ import { LibraryList } from "./library-list";
import { getItemKey } from "./library-rows";
import { LibrarySkeleton } from "./library-skeleton";
import { LibraryToolbar } from "./library-toolbar";
import { SourceNotice } from "./source-notice";
export const LibraryView = ({ token }: { token: string }) => {
const { query, items, sourceCounts, summary, isFiltered, isEmptyLibrary } =
@@ -76,11 +76,7 @@ export const LibraryView = ({ token }: { token: string }) => {
{notices.length > 0 && (
<div className="flex flex-col gap-2">
{notices.map(([source, detail]) => (
<Alert key={source} variant="destructive">
<TriangleAlertIcon />
<AlertTitle>{SOURCE_LABELS[source]} 拉取失败</AlertTitle>
<AlertDescription>{detail}</AlertDescription>
</Alert>
<SourceNotice key={source} source={source} detail={detail} />
))}
</div>
)}
+8 -2
View File
@@ -33,7 +33,10 @@ const MenuFilters = () => {
const resetFacets = useLibraryFilterStore((state) => state.resetFacets);
return (
<section aria-labelledby="mobile-menu-filters" className="flex flex-col gap-3">
<section
aria-labelledby="mobile-menu-filters"
className="flex flex-col gap-3"
>
<div className="flex items-center">
<h3 id="mobile-menu-filters" className="text-sm font-medium">
筛选
@@ -59,7 +62,10 @@ const MenuPlaylist = ({ token }: { token: string }) => {
const playlist = usePlaylist(token);
return (
<section aria-labelledby="mobile-menu-playlist" className="flex flex-col gap-1">
<section
aria-labelledby="mobile-menu-playlist"
className="flex flex-col gap-1"
>
<h3
id="mobile-menu-playlist"
className="flex items-center gap-2 pb-2 text-sm font-medium"
@@ -0,0 +1,37 @@
// 来源拉取失败的提示:因 TorBox 限流失败时显示倒计时,额度恢复后资源列表自动重新拉取
"use client";
import { TriangleAlertIcon } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { SOURCE_LABELS } from "@/constants/torbox";
import { useCountdown } from "@/hooks/use-countdown";
import { useLibrary } from "@/hooks/use-library";
import type { SourceType } from "@/types/torbox";
type SourceNoticeProps = {
source: SourceType;
detail: string;
};
export const SourceNotice = ({ source, detail }: SourceNoticeProps) => {
const { data, dataUpdatedAt, isFetching } = useLibrary();
// 重新拉取会刷新全部来源,所以限流时每条提示都带上倒计时
const retryAt = data?.retryAfterMs ? dataUpdatedAt + data.retryAfterMs : null;
const seconds = useCountdown(retryAt);
let retry = "";
if (seconds > 0) retry = `,${seconds} 秒后自动重试`;
else if (retryAt !== null && isFetching) retry = ",正在重试";
return (
<Alert variant="destructive">
<TriangleAlertIcon />
<AlertTitle>{SOURCE_LABELS[source]} 拉取失败</AlertTitle>
<AlertDescription>
{detail}
{retry}
</AlertDescription>
</Alert>
);
};
+15 -6
View File
@@ -2,8 +2,12 @@
import type { NextRequest } from "next/server";
import { guard } from "@/server/auth";
import { createLogger, mediaRef } from "@/server/logger";
import { jsonError, withApi } from "@/server/response";
import { jsonError, jsonUpstreamError, withApi } from "@/server/response";
import { createStream, isSourceType } from "@/server/torbox";
import {
getRateLimitHeaders,
getResponseHeaders
} from "@/server/torbox-limiter";
export const dynamic = "force-dynamic";
@@ -36,7 +40,15 @@ export const GET = withApi(async (req: NextRequest, { params }: Ctx) => {
});
if (!upstream.ok) {
log.warn(
{ ...media, upstreamStatus: upstream.status },
{
...media,
upstreamStatus: upstream.status,
// 节点限流的规则尚不明确,429 时记下全部响应头
headers:
upstream.status === 429
? getResponseHeaders(upstream.headers)
: getRateLimitHeaders(upstream.headers)
},
"转码列表请求失败"
);
return jsonError(502, `TorBox 播放列表请求失败(${upstream.status})`);
@@ -62,9 +74,6 @@ export const GET = withApi(async (req: NextRequest, { params }: Ctx) => {
} catch (error) {
if (req.signal.aborted) log.debug(media, "客户端中断请求");
else log.error({ ...media, err: error }, "转码播放失败");
return jsonError(
502,
error instanceof Error ? error.message : "upstream error"
);
return jsonUpstreamError(error);
}
});
+2 -5
View File
@@ -2,7 +2,7 @@
import type { NextRequest } from "next/server";
import { guard } from "@/server/auth";
import { createLogger } from "@/server/logger";
import { jsonError, jsonOk, withApi } from "@/server/response";
import { jsonOk, jsonUpstreamError, withApi } from "@/server/response";
import { getLibrary } from "@/server/torbox";
const log = createLogger("library");
@@ -29,9 +29,6 @@ export const GET = withApi(async (req: NextRequest) => {
return jsonOk(data);
} catch (error) {
log.error({ err: error }, "资源列表获取失败");
return jsonError(
502,
error instanceof Error ? error.message : "upstream error"
);
return jsonUpstreamError(error);
}
});
+15
View File
@@ -0,0 +1,15 @@
// TorBox 限流状态接口:资源因限流加载失败后,前端据此等到额度恢复再自动重新加载
import type { NextRequest } from "next/server";
import { guard } from "@/server/auth";
import { jsonOk, withApi } from "@/server/response";
import { getTorboxLimits } from "@/server/torbox-limiter";
import type { TorboxLimitsResponse } from "@/types/torbox";
export const dynamic = "force-dynamic";
/** 只读本地记录,不请求 TorBox */
export const GET = withApi(async (req: NextRequest) => {
const denied = guard(req);
if (denied) return denied;
return jsonOk<TorboxLimitsResponse>({ limits: getTorboxLimits() });
});
+2 -5
View File
@@ -2,7 +2,7 @@
import type { NextRequest } from "next/server";
import { guard } from "@/server/auth";
import { createLogger } from "@/server/logger";
import { jsonError, withApi } from "@/server/response";
import { jsonUpstreamError, withApi } from "@/server/response";
import { getLibrary } from "@/server/torbox";
import { buildStreamUrl, getBaseName, getMediaKind } from "@/utils/media";
@@ -46,9 +46,6 @@ export const GET = withApi(async (req: NextRequest) => {
});
} catch (error) {
log.error({ err: error }, "播放列表生成失败");
return jsonError(
502,
error instanceof Error ? error.message : "upstream error"
);
return jsonUpstreamError(error);
}
});
@@ -4,8 +4,12 @@ import { guard } from "@/server/auth";
import { isMockMode } from "@/server/env";
import { createLogger, mediaRef } from "@/server/logger";
import { getMockImage } from "@/server/mock-library";
import { jsonError, withApi } from "@/server/response";
import { jsonError, jsonUpstreamError, withApi } from "@/server/response";
import { isSourceType, requestDownloadUrl } from "@/server/torbox";
import {
getRateLimitHeaders,
getResponseHeaders
} from "@/server/torbox-limiter";
import { getFileExt, getMediaKind } from "@/utils/media";
export const dynamic = "force-dynamic";
@@ -17,10 +21,12 @@ const PASS_HEADERS = [
"content-range",
"accept-ranges",
"last-modified",
"etag"
"etag",
// 下载节点 429 时告诉播放器多久后再试
"retry-after"
];
// 直链的下载节点偶尔会拒绝刚签发的直链(400 Invalid Presigned Token),重新换一条即可
// 直链的下载节点偶尔会拒绝直链(400 Invalid Presigned Token),重新签发一条即可
const MAX_ATTEMPTS = 3;
const KIND_LABEL = { video: "播放", image: "图片", other: "文件" } as const;
@@ -79,8 +85,14 @@ const proxy = async (
try {
let upstream: Response;
/** 下载节点:实测同一节点并发超过 20 会 429,出错时记录便于核对 */
let cdnHost = "";
for (let attempt = 1; ; attempt += 1) {
const url = await requestDownloadUrl(type, id, fileId);
// 首次用缓存的直链,被拒绝后重新签发
const url = await requestDownloadUrl(type, id, fileId, {
fresh: attempt > 1
});
cdnHost = new URL(url).host;
upstream = await fetch(url, {
method,
headers: range ? { Range: range } : undefined,
@@ -106,8 +118,14 @@ const proxy = async (
{
...media,
method,
cdnHost,
upstreamStatus: upstream.status,
contentType: upstream.headers.get("content-type")
contentType: upstream.headers.get("content-type"),
// 节点限流的规则尚不明确,429 时记下全部响应头
headers:
upstream.status === 429
? getResponseHeaders(upstream.headers)
: getRateLimitHeaders(upstream.headers)
},
"上游返回异常"
);
@@ -125,10 +143,7 @@ const proxy = async (
// 播放器关闭或跳转时会主动断开,不算故障
if (req.signal.aborted) log.debug(media, "客户端中断请求");
else log.error({ ...media, method, err: error }, "播放失败");
return jsonError(
502,
error instanceof Error ? error.message : "upstream error"
);
return jsonUpstreamError(error);
}
};
+7 -5
View File
@@ -3,7 +3,12 @@ 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 {
jsonError,
jsonOk,
jsonUpstreamError,
withApi
} from "@/server/response";
import { addMagnet } from "@/server/torbox";
import { isMagnetLink } from "@/utils/magnet";
@@ -27,9 +32,6 @@ export const POST = withApi(async (req: NextRequest) => {
} catch (error) {
// TorBox 的失败原因(名额已满、限流等)对用户有意义,原样透出
log.error({ err: error }, "添加 magnet 失败");
return jsonError(
502,
error instanceof Error ? error.message : "upstream error"
);
return jsonUpstreamError(error);
}
});
+27 -1
View File
@@ -1,11 +1,12 @@
// 网页视频播放器:Video.js v10 + 项目内安装的 shadcn 皮肤
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { useCallback, useEffect, useRef, type ReactNode } from "react";
import dynamic from "next/dynamic";
import { ChevronLeftIcon } from "lucide-react";
import { I18nProvider } from "@videojs/react";
import zhCN from "@videojs/react/i18n/locales/zh-CN";
import type { HlsJsAdapter } from "@videojs/react/media/hlsjs-video";
import { Video, VideoPlayer as Player } from "@videojs/react/video";
import { importHlsJsVideo } from "@/components/video-player-loader";
@@ -33,6 +34,8 @@ type VideoPlayerProps = {
onEnded?: () => void;
/** 替换默认的播放错误说明 */
errorDescription?: ReactNode;
/** 媒体加载或播放出错(HLS 包括播放列表、分片请求失败);传入稳定的引用,变化时会重新绑定 */
onError?: () => void;
/** 提供时在画面左上角显示关闭按钮,随控制栏显隐 */
onClose?: () => void;
/** 尺寸与比例,如 aspect-video */
@@ -52,11 +55,31 @@ export const VideoPlayer = ({
autoFocus,
onEnded,
errorDescription,
onError,
onClose,
className
}: VideoPlayerProps) => {
const rootRef = useRef<HTMLDivElement>(null);
// hls.js 的致命错误(如播放列表请求失败)只派发在适配器上,<video> 本身不会触发 error
const hlsMediaRef = useCallback(
(adapter: HlsJsAdapter | null) => {
if (!adapter || !onError) return;
adapter.addEventListener("error", onError);
return () => adapter.removeEventListener("error", onError);
},
[onError]
);
// <video> 一创建就开始请求:请求很快失败(如本地限流的 429)时,error 在挂载前就已触发,
// React 不会派发给 onError,挂载时补查一次
const videoRef = useCallback(
(video: HTMLVideoElement | null) => {
if (video?.error) onError?.();
},
[onError]
);
useEffect(() => {
if (!autoFocus) return;
// 皮肤的容器(.media-skin)可聚焦,但组件没有暴露 ref
@@ -94,14 +117,17 @@ export const VideoPlayer = ({
playsInline
preload="metadata"
onEnded={onEnded}
mediaRef={hlsMediaRef}
/>
) : (
<Video
ref={videoRef}
src={src}
autoPlay={autoPlay}
playsInline
preload="metadata"
onEnded={onEnded}
onError={onError}
/>
)}
</VideoSkin>
+89
View File
@@ -0,0 +1,89 @@
// TorBox API 限流配置:额度按 API Key 计算(不是按 IP),同一把 Key 的所有客户端共用
// 官方:https://documenter.getpostman.com/view/29572726/2s9YXo1zX4
// 实测:https://notes.debridmediamanager.com/providers/torbox/
// 这把 Key 还给其他客户端(zurg、播放器插件等)用时,按比例调低下面的额度
// 请求记录、429 暂停、直链缓存都存在 SQLite,重启后继续生效
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
export type TorboxLimitRule = {
/** 提示语里的操作名,如「获取播放地址过于频繁,请 3 分钟后再试」;以中文结尾,后面直接接「过于频繁」 */
label: string;
/** 任意 windowMs 时长内最多 limit 次(滑动窗口) */
limit: number;
windowMs: number;
/** 匹配不含查询串的接口路径,如 /torrents/requestdl;不提供时作用于全部接口 */
match?: RegExp;
};
/**
* 全部额度,键是额度 id(存入数据库、下发前端,改名会丢掉已有记录)。
* 一个请求要扣减它命中的每条额度,每条都有余量才发出
*/
export const TORBOX_LIMITS = {
/** 整把 Key 的总预算:官方 300 次/分钟,压到 240 给重试和共用同一把 Key 的客户端留余量 */
global: {
label: "TorBox 请求",
limit: 240,
windowMs: MINUTE
},
/** 官方:未缓存的 60 次/小时,已缓存的只占总预算。发出前无法区分是否已缓存,一律计入 */
createTorrent: {
label: "添加种子",
match: /^\/torrents\/createtorrent$/,
limit: 60,
windowMs: HOUR
},
createUsenet: {
label: "添加 Usenet 下载",
match: /^\/usenet\/createusenetdownload$/,
limit: 60,
windowMs: HOUR
},
createWebdl: {
label: "添加网页下载",
match: /^\/webdl\/createwebdownload$/,
limit: 60,
windowMs: HOUR
},
/**
* 文档未写。实测 1 秒 1 次、2 秒 1 次都在约第 100 次 429,Retry-After 300 秒,
* 约 20 次/分钟才不容易触发,即 5 分钟约 100 次的额度。取 80,平均 16 次/分钟,
* 同时容许图片墙一次加载几十张
*/
requestDownload: {
label: "获取播放地址",
match: /^\/(torrents|usenet|webdl)\/requestdl$/,
limit: 80,
windowMs: 5 * MINUTE
}
} satisfies Record<string, TorboxLimitRule>;
export type TorboxLimitId = keyof typeof TORBOX_LIMITS;
/**
* 前端各类资源受哪些额度影响:因限流加载失败后,等这些额度都恢复再自动重新加载。
* file:/api/stream(图片、直接播放的视频),hls:/api/hls(转码播放)
*/
export const TORBOX_LIMITS_BY_RESOURCE = {
file: ["global", "requestDownload"],
hls: ["global"]
} as const satisfies Record<string, readonly TorboxLimitId[]>;
export type TorboxLimitedResource = keyof typeof TORBOX_LIMITS_BY_RESOURCE;
/** 同时进行中的 API 请求上限:实测约 40 个并发会把全部接口一起限住 */
export const TORBOX_MAX_CONCURRENT = 10;
/** 本地额度用尽时最多排队等这么久,超过直接提示稍后再试,不让请求长时间挂起 */
export const TORBOX_MAX_WAIT_MS = 10 * 1000;
/** TorBox 返回 429 却没带 Retry-After 时,暂停对应额度的时长 */
export const TORBOX_DEFAULT_RETRY_AFTER_MS = MINUTE;
/**
* 直链缓存时长:直链约 3 小时有效,缓存 1 小时留足余量。
* 同一文件的拖动进度、HEAD 后再 GET、重新打开都复用同一条,不再重复签发
*/
export const TORBOX_DOWNLOAD_URL_TTL_MS = HOUR;
+22
View File
@@ -0,0 +1,22 @@
// 倒计时 hook:距目标时间还剩多少秒,每秒刷新
import { useSyncExternalStore } from "react";
const subscribeSeconds = (onTick: () => void) => {
const timer = setInterval(onTick, 1000);
return () => clearInterval(timer);
};
const subscribeNone = () => () => {};
/** 当前时间(整秒),同一秒内多次读取返回同一个值 */
const getSeconds = () => Math.floor(Date.now() / 1000);
/** 距 until(毫秒时间戳)还剩的秒数;until 为空或已过时返回 0。只在有目标时间时计时 */
export const useCountdown = (until: number | null) => {
const now = useSyncExternalStore(
until === null ? subscribeNone : subscribeSeconds,
getSeconds,
() => 0
);
return until === null ? 0 : Math.max(0, Math.ceil(until / 1000 - now));
};
+4 -2
View File
@@ -42,7 +42,9 @@ export const useLibrary = () =>
useQuery({
queryKey: LIBRARY_QUERY_KEY,
queryFn: () => getLibrary(),
retry: false
retry: false,
// 有来源因 TorBox 限流没拉到时,等额度恢复后自动重新拉取;拉全了就停
refetchInterval: (query) => query.state.data?.retryAfterMs ?? false
});
/**
@@ -54,7 +56,7 @@ export const useRefreshLibrary = () => {
return useCallback(
() =>
queryClient
.fetchQuery({
.query({
queryKey: LIBRARY_QUERY_KEY,
queryFn: () => getLibrary({ fresh: true }),
staleTime: 0
+79
View File
@@ -0,0 +1,79 @@
// 资源因 TorBox 限流加载失败后,等额度恢复再自动重新加载
import { useCallback, useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
TORBOX_LIMITS_BY_RESOURCE,
type TorboxLimitedResource
} from "@/constants/torbox-limits";
import { getTorboxLimits } from "@/services/limits";
const LIMITS_QUERY_KEY = ["torbox-limits"] as const;
/** 图片墙同一批失败的图片在这段时间内共用一次查询 */
const LIMITS_STALE_MS = 1000;
/** 服务端给的是剩余毫秒数,收到时换算成本机时间,避免两边时钟不一致 */
const fetchLimits = async () => {
const { limits } = await getTorboxLimits();
const now = Date.now();
return limits.map(({ id, label, retryAfterMs }) => ({
id,
label,
until: now + retryAfterMs
}));
};
export type LimitWait = {
/** 受限的操作名,如「获取播放地址」 */
label: string;
/** 恢复时间,本机毫秒时间戳 */
until: number;
};
/**
* 加载失败时调用 onError:资源所依赖的额度正受限,就在恢复时递增 reloadKey(调用方以它作为 key 重新挂载资源)
* 并返回 true;没有受限返回 false,由调用方按普通失败处理。等待期间 wait 不为空,可用来显示倒计时
*/
export const useLimitRetry = (resource: TorboxLimitedResource) => {
const queryClient = useQueryClient();
const [reloadKey, setReloadKey] = useState(0);
const [wait, setWait] = useState<LimitWait | null>(null);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
const mounted = useRef(false);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
clearTimeout(timer.current);
};
}, []);
const onError = useCallback(async () => {
const ids: readonly string[] = TORBOX_LIMITS_BY_RESOURCE[resource];
const limits = await queryClient
.query({
queryKey: LIMITS_QUERY_KEY,
queryFn: fetchLimits,
staleTime: LIMITS_STALE_MS
})
.catch(() => []);
// 要等最晚恢复的那条
const blocking = limits
.filter((limit) => ids.includes(limit.id))
.sort((a, b) => b.until - a.until)[0];
const delay = blocking ? blocking.until - Date.now() : 0;
if (delay <= 0 || !mounted.current) return false;
setWait({ label: blocking.label, until: blocking.until });
clearTimeout(timer.current);
timer.current = setTimeout(() => {
setWait(null);
setReloadKey((key) => key + 1);
}, delay);
return true;
}, [queryClient, resource]);
return { reloadKey, wait, onError };
};
+4 -1
View File
@@ -1,8 +1,11 @@
// 服务端启动钩子(环境变量校验)
// 服务端启动钩子(环境变量校验、数据库迁移)
export async function register() {
// 仅在 Node.js 服务端运行时校验:未配置 TorBox 时进入演示模式,配置不安全时终止启动
if (process.env.NEXT_RUNTIME === "nodejs") {
const { assertServerEnv } = await import("@/server/env");
assertServerEnv();
// 迁移失败时终止启动,不带着缺表的数据库运行
const { runMigrations } = await import("@/server/db/migrate");
runMigrations();
}
}
+7 -2
View File
@@ -7,10 +7,15 @@ import { getDbFileName } from "@/server/env";
const createDb = () => {
const file = getDbFileName();
mkdirSync(dirname(file), { recursive: true });
return drizzle({ connection: { path: file } });
const db = drizzle({ connection: { path: file } });
// node:sqlite 是同步驱动,写入会阻塞事件循环;WAL + NORMAL 下提交不再逐次刷盘
db.$client.exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;");
return db;
};
// 开发模式热更新会重复执行模块,缓存到 globalThis 避免重复打开数据库
const globalForDb = globalThis as unknown as { db?: ReturnType<typeof createDb> };
const globalForDb = globalThis as unknown as {
db?: ReturnType<typeof createDb>;
};
export const db = (globalForDb.db ??= createDb());
+8
View File
@@ -0,0 +1,8 @@
// 启动时应用数据库迁移:Docker 镜像里没有 drizzle-kit,不能靠 `pnpm db:migrate`
import { join } from "node:path";
import { migrate } from "drizzle-orm/node-sqlite/migrator";
import { db } from "@/server/db";
/** 迁移文件由 `pnpm db:generate` 生成在项目根目录的 drizzle/,镜像里复制到同样位置 */
export const runMigrations = () =>
migrate(db, { migrationsFolder: join(process.cwd(), "drizzle") });
+31 -3
View File
@@ -1,4 +1,32 @@
// 数据库表结构(drizzle-kit 以此生成迁移)
// 使用 `import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core"` 定义表后,
// 运行 `pnpm db:generate` 生成迁移,`pnpm db:migrate` 应用
export {};
// 修改后运行 `pnpm db:generate` 生成迁移;服务启动时自动应用(src/server/db/migrate.ts)
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
/** TorBox 直链缓存:requestdl 额度很紧,同一文件在有效期内复用同一条直链 */
export const torboxDownloadUrls = sqliteTable("torbox_download_urls", {
/** 来源/资源 id/文件 id,如 torrents/123/4 */
key: text().primaryKey(),
url: text().notNull(),
/** 毫秒时间戳,过期后重新签发 */
expiresAt: integer("expires_at").notNull()
});
/** TorBox 请求记录(滑动窗口限流):每发出一次请求,在它扣减的每条额度下各记一行 */
export const torboxRateHits = sqliteTable(
"torbox_rate_hits",
{
id: integer().primaryKey({ autoIncrement: true }),
/** 额度 id,见 TORBOX_LIMITS */
rule: text().notNull(),
/** 发出时间,毫秒时间戳 */
at: integer().notNull()
},
(table) => [index("torbox_rate_hits_rule_at_idx").on(table.rule, table.at)]
);
/** TorBox 返回 429 后的暂停:until 之前该额度不再发请求,提前重试会把锁定拖长 */
export const torboxRatePauses = sqliteTable("torbox_rate_pauses", {
rule: text().primaryKey(),
/** 毫秒时间戳 */
until: integer().notNull()
});
+9
View File
@@ -1,5 +1,6 @@
// 接口统一响应:{ code, data, msg }
import { createLogger } from "@/server/logger";
import { RateLimitError } from "@/server/torbox-limiter";
import type { ApiResponse } from "@/types/api";
const log = createLogger("api");
@@ -23,6 +24,14 @@ export const jsonError = (
headers: { ...NO_STORE, ...headers }
});
/** 上游(TorBox)失败:额度用尽返回 429 并带 Retry-After,其余返回 502 并透出原因 */
export const jsonUpstreamError = (error: unknown) =>
error instanceof RateLimitError
? jsonError(429, error.message, {
"Retry-After": String(Math.ceil(error.retryAfterMs / 1000))
})
: jsonError(502, error instanceof Error ? error.message : "upstream error");
/** 路由兜底:未被处理的异常也按统一结构返回 500,不暴露内部错误信息 */
export const withApi =
<Args extends unknown[]>(handler: (...args: Args) => Promise<Response>) =>
+271
View File
@@ -0,0 +1,271 @@
// TorBox API 限流:所有 TorBox 接口请求都经过这里,额度见 constants/torbox-limits
// 请求记录与 429 暂停存在 SQLite,重启后继续生效;并发名额是本进程正在进行的请求,只在内存里
import { and, asc, count, eq, gt, lte, sql } from "drizzle-orm";
import {
TORBOX_DEFAULT_RETRY_AFTER_MS,
TORBOX_LIMITS,
TORBOX_MAX_CONCURRENT,
TORBOX_MAX_WAIT_MS,
type TorboxLimitId,
type TorboxLimitRule
} from "@/constants/torbox-limits";
import { db } from "@/server/db";
import { torboxRateHits, torboxRatePauses } from "@/server/db/schema";
import { createLogger } from "@/server/logger";
import type { TorboxLimitState } from "@/types/torbox";
const log = createLogger("torbox-limiter");
const LIMIT_IDS = Object.keys(TORBOX_LIMITS) as TorboxLimitId[];
const getRule = (id: TorboxLimitId): TorboxLimitRule => TORBOX_LIMITS[id];
const formatWait = (ms: number) =>
ms < 60 * 1000
? `${Math.ceil(ms / 1000)} 秒`
: `${Math.ceil(ms / 60 / 1000)} 分钟`;
/** 额度暂时用尽:路由据此返回 429 和 Retry-After */
export class RateLimitError extends Error {
constructor(
readonly limitId: TorboxLimitId,
readonly retryAfterMs: number
) {
super(
`${getRule(limitId).label}过于频繁,请 ${formatWait(retryAfterMs)}后再试`
);
this.name = "RateLimitError";
}
/** 不带等待时间的原因,等待时间另行下发时使用 */
get reason() {
return `${getRule(this.limitId).label}过于频繁`;
}
}
/** 该接口要扣减的额度:总预算 + 命中的接口额度,越具体的越靠后 */
const getLimitIds = (endpoint: string) =>
LIMIT_IDS.filter((id) => getRule(id).match?.test(endpoint) ?? true);
/**
* 该额度窗口内已用次数,以及距可用还要多久:
* 暂停中取剩余暂停时间,窗口已满取足够多的记录滑出窗口的时间
*/
const getUsage = (id: TorboxLimitId, now: number) => {
const rule = getRule(id);
const inWindow = and(
eq(torboxRateHits.rule, id),
gt(torboxRateHits.at, now - rule.windowMs)
);
const used =
db.select({ value: count() }).from(torboxRateHits).where(inWindow).get()
?.value ?? 0;
let windowWait = 0;
if (used >= rule.limit) {
// 窗口内第 used - limit + 1 早的记录滑出后才有余量
const row = db
.select({ at: torboxRateHits.at })
.from(torboxRateHits)
.where(inWindow)
.orderBy(asc(torboxRateHits.at))
.limit(1)
.offset(used - rule.limit)
.get();
if (row) windowWait = row.at + rule.windowMs - now;
}
const pausedUntil =
db
.select({ until: torboxRatePauses.until })
.from(torboxRatePauses)
.where(eq(torboxRatePauses.rule, id))
.get()?.until ?? 0;
return { used, waitMs: Math.max(pausedUntil - now, windowWait, 0) };
};
/** 各额度窗口内的用量,写进日志用来校准额度(实际限制尚不明确) */
const describeUsage = (ids: TorboxLimitId[], now: number) =>
Object.fromEntries(
ids.map((id) => {
const { limit, windowMs } = getRule(id);
return [
id,
{ used: getUsage(id, now).used, limit, windowSec: windowMs / 1000 }
];
})
);
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* 在全部额度里各记一次;等待超过 TORBOX_MAX_WAIT_MS 的直接拒绝,不占额度。
* 驱动是同步的:检查与记录之间不会穿插其他请求
*/
const reserve = async (endpoint: string) => {
const ids = getLimitIds(endpoint);
const deadline = Date.now() + TORBOX_MAX_WAIT_MS;
for (;;) {
const now = Date.now();
let waitMs = 0;
let blocking = ids[0];
for (const id of ids) {
const usage = getUsage(id, now);
if (usage.waitMs > waitMs) {
waitMs = usage.waitMs;
blocking = id;
}
}
if (waitMs === 0) {
db.transaction((tx) => {
for (const id of ids) {
// 顺带清掉已滑出窗口的记录
tx.delete(torboxRateHits)
.where(
and(
eq(torboxRateHits.rule, id),
lte(torboxRateHits.at, now - getRule(id).windowMs)
)
)
.run();
tx.insert(torboxRateHits).values({ rule: id, at: now }).run();
}
});
return;
}
if (now + waitMs > deadline) {
log.warn(
{
endpoint,
rule: blocking,
retryAfterMs: waitMs,
usage: describeUsage(ids, now)
},
"TorBox 额度用尽,拒绝请求"
);
throw new RateLimitError(blocking, waitMs);
}
log.debug(
{ endpoint, rule: blocking, waitMs },
"TorBox 额度用尽,排队等待"
);
await sleep(waitMs);
}
};
type SlotState = {
active: number;
/** 等待并发名额的请求,先到先得 */
queue: (() => void)[];
};
// 挂在 globalThis 上,避免各路由打包后各持一份
const SLOTS_KEY = Symbol.for("torbox-admin.torbox-slots");
const globals = globalThis as { [SLOTS_KEY]?: SlotState };
const slots: SlotState = (globals[SLOTS_KEY] ??= { active: 0, queue: [] });
const acquireSlot = () => {
if (slots.active < TORBOX_MAX_CONCURRENT) {
slots.active += 1;
return Promise.resolve();
}
return new Promise<void>((resolve) => slots.queue.push(resolve));
};
/** 名额直接交给下一个等待者,active 不变 */
const releaseSlot = () => {
const next = slots.queue.shift();
if (next) next();
else slots.active -= 1;
};
/** 先扣额度再占并发名额,task 结束(含读取响应体)后释放名额 */
export const limitTorbox = async <T>(
endpoint: string,
task: () => Promise<T>
) => {
await reserve(endpoint);
await acquireSlot();
try {
return await task();
} finally {
releaseSlot();
}
};
/** Retry-After 可以是秒数或 HTTP 日期 */
const parseRetryAfter = (value: string | null) => {
if (!value) return TORBOX_DEFAULT_RETRY_AFTER_MS;
const seconds = Number(value);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
const date = Date.parse(value);
return Number.isNaN(date)
? TORBOX_DEFAULT_RETRY_AFTER_MS
: Math.max(0, date - Date.now());
};
/** 响应里与限流有关的头(Retry-After、X-RateLimit-* 等),没有时返回 undefined */
export const getRateLimitHeaders = (headers: Headers) => {
const picked = [...headers].filter(([name]) =>
/^(retry-after|x-ratelimit|ratelimit)/i.test(name)
);
return picked.length ? Object.fromEntries(picked) : undefined;
};
/** 全部响应头,set-cookie 除外 */
export const getResponseHeaders = (headers: Headers) =>
Object.fromEntries([...headers].filter(([name]) => name !== "set-cookie"));
/** 429 响应体只记录开头,足够看出 TorBox 的错误码与说明 */
const MAX_LOGGED_BODY = 2000;
/**
* TorBox 返回 429:暂停该接口最具体的那条额度(没有接口额度时暂停总预算),
* 等满 Retry-After 前不再发请求,提前重试会把锁定拖长。
* 实际限制尚不明确,响应头、响应体和本地用量全部记入日志。返回要抛出的错误
*/
export const pauseTorbox = async (endpoint: string, res: Response) => {
const ids = getLimitIds(endpoint);
const id = ids.at(-1)!;
const retryAfter = res.headers.get("retry-after");
const retryAfterMs = parseRetryAfter(retryAfter);
const now = Date.now();
const until = now + retryAfterMs;
db.insert(torboxRatePauses)
.values({ rule: id, until })
.onConflictDoUpdate({
target: torboxRatePauses.rule,
set: { until: sql`max(${torboxRatePauses.until}, ${until})` }
})
.run();
const body = await res.text().catch(() => "");
log.warn(
{
endpoint,
status: res.status,
rule: id,
retryAfter,
retryAfterMs,
headers: getResponseHeaders(res.headers),
body: body.slice(0, MAX_LOGGED_BODY),
usage: describeUsage(ids, now)
},
"TorBox 返回 429,暂停请求"
);
return new RateLimitError(id, retryAfterMs);
};
/** 当前受限的额度及多久后恢复,供前端在限流结束后自动重新加载 */
export const getTorboxLimits = (): TorboxLimitState[] => {
const now = Date.now();
return LIMIT_IDS.flatMap((id) => {
const { waitMs } = getUsage(id, now);
return waitMs > 0
? [{ id, label: getRule(id).label, retryAfterMs: waitMs }]
: [];
});
};
+142 -34
View File
@@ -2,7 +2,17 @@
import { getTorboxApiKey, isMockMode } from "@/server/env";
import { createLogger } from "@/server/logger";
import { getMockLibrary } from "@/server/mock-library";
import { and, eq, gt, lte } from "drizzle-orm";
import { db } from "@/server/db";
import { torboxDownloadUrls } from "@/server/db/schema";
import {
getRateLimitHeaders,
limitTorbox,
pauseTorbox,
RateLimitError
} from "@/server/torbox-limiter";
import { SOURCES, TORBOX_API } from "@/constants/torbox";
import { TORBOX_DOWNLOAD_URL_TTL_MS } from "@/constants/torbox-limits";
import type {
AddTorrentResult,
DownloadStatus,
@@ -54,31 +64,39 @@ const apiKey = getTorboxApiKey;
const log = createLogger("torbox");
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"
},
signal: AbortSignal.timeout(15000),
cache: "no-store"
});
/** 所有 TorBox 接口请求的唯一出口,经过统一限流 */
const torboxFetch = (path: string, init: RequestInit = {}) => {
// 只记录接口路径:查询串里有资源 id,requestdl 的查询串还带着 API Key
log.debug(
{
endpoint: path.split("?")[0],
status: res.status,
durationMs: Date.now() - startedAt
},
"TorBox 请求"
);
const body = await res.json().catch(() => ({}));
if (!res.ok || body.success === false)
throw new Error(body.detail ?? res.statusText);
return body.data;
const endpoint = path.split("?")[0];
return limitTorbox(endpoint, async () => {
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"
},
signal: AbortSignal.timeout(15000),
cache: "no-store"
});
if (res.status === 429) throw await pauseTorbox(endpoint, res);
// 实际限制尚不明确:响应带了限流相关的头就记为 info,便于对照额度
const rateLimitHeaders = getRateLimitHeaders(res.headers);
log[rateLimitHeaders ? "info" : "debug"](
{
endpoint,
status: res.status,
durationMs: Date.now() - startedAt,
rateLimitHeaders
},
"TorBox 请求"
);
const body = await res.json().catch(() => ({}));
if (!res.ok || body.success === false)
throw new Error(body.detail ?? res.statusText);
return body.data;
});
};
export const isSourceType = (value: string): value is SourceType =>
@@ -89,8 +107,18 @@ const PAGE_SIZE = 1000;
/** 翻页上限:防止接口忽略 offset 或数据异常时无限请求 */
const MAX_PAGES = 50;
const errorMessage = (error: unknown) =>
error instanceof Error ? error.message : "unknown";
/** 限流时只给原因,等待时间由 retryAfterMs 另行下发,前端据此倒计时并自动重试 */
const errorMessage = (error: unknown) => {
if (error instanceof RateLimitError) return error.reason;
return error instanceof Error ? error.message : "unknown";
};
type PagesResult = {
items: RawItem[];
error?: string;
/** 因限流中断时,多久后额度恢复 */
retryAfterMs?: number;
};
/**
* 按 offset 翻页拉取某个来源的全部资源。
@@ -101,7 +129,7 @@ const errorMessage = (error: unknown) =>
const fetchAllPages = async (
list: string,
bypassCache: boolean
): Promise<{ items: RawItem[]; error?: string }> => {
): Promise<PagesResult> => {
const items: RawItem[] = [];
const seen = new Set<number>();
@@ -116,10 +144,14 @@ const fetchAllPages = async (
try {
data = (await torboxFetch(`${list}?${query}`)) ?? [];
} catch (error) {
if (items.length === 0) return { items, error: errorMessage(error) };
const retryAfterMs =
error instanceof RateLimitError ? error.retryAfterMs : undefined;
return {
items,
error: `已加载前 ${items.length} 条,后续拉取失败:${errorMessage(error)}`
error: items.length
? `已加载前 ${items.length} 条,后续拉取失败:${errorMessage(error)}`
: errorMessage(error),
retryAfterMs
};
}
@@ -143,6 +175,7 @@ export const getLibrary = async ({
const errors: LibraryResponse["errors"] = {};
const items: LibraryItem[] = [];
let retryAfterMs: number | undefined;
// 三个来源并行,各自内部按页顺序拉取
const results = await Promise.all(
@@ -153,8 +186,11 @@ export const getLibrary = async ({
);
// 按 SOURCES 的固定顺序合并,来源内保持接口返回的顺序,不受请求完成先后影响
for (const { type, items: data, error } of results) {
for (const { type, items: data, error, ...result } of results) {
if (error) errors[type] = error;
// 等到最晚恢复的那条额度,届时重新拉取能拿到全部来源
if (result.retryAfterMs !== undefined)
retryAfterMs = Math.max(retryAfterMs ?? 0, result.retryAfterMs);
for (const item of data) {
items.push({
id: item.id,
@@ -179,7 +215,7 @@ export const getLibrary = async ({
}
}
return { items, errors };
return { items, errors, retryAfterMs };
};
/** 从已校验的 magnet 中取出 info hash(小写) */
@@ -239,13 +275,11 @@ export const checkCached = async (hashes: string[]): Promise<Set<string>> => {
const MOCK_PLAY_ERROR =
"演示模式没有可播放的文件,配置 TORBOX_API_KEY 后可播放真实资源";
/** 向 TorBox 换取临时直链(仅服务端使用,不下发给客户端) */
export const requestDownloadUrl = async (
const signDownloadUrl = async (
type: SourceType,
id: string,
fileId: string
) => {
if (isMockMode()) throw new Error(MOCK_PLAY_ERROR);
const source = SOURCES.find((s) => s.type === type)!;
const params = new URLSearchParams({
token: apiKey(),
@@ -258,6 +292,80 @@ export const requestDownloadUrl = async (
return url;
};
const getCachedUrl = (key: string, now: number) =>
db
.select({ url: torboxDownloadUrls.url })
.from(torboxDownloadUrls)
.where(
and(
eq(torboxDownloadUrls.key, key),
gt(torboxDownloadUrls.expiresAt, now)
)
)
.get()?.url;
const saveUrl = (key: string, url: string, now: number) => {
const expiresAt = now + TORBOX_DOWNLOAD_URL_TTL_MS;
db.transaction((tx) => {
// 顺带清掉已过期的
tx.delete(torboxDownloadUrls)
.where(lte(torboxDownloadUrls.expiresAt, now))
.run();
tx.insert(torboxDownloadUrls)
.values({ key, url, expiresAt })
.onConflictDoUpdate({
target: torboxDownloadUrls.key,
set: { url, expiresAt }
})
.run();
});
};
// 正在签发的直链(不是缓存,签发结束即移除):同一文件同时到来的请求
// (播放器连发的 Range、HEAD 后再 GET)共用一次签发。挂在 globalThis 上,避免各路由打包后各持一份
const SIGNING_KEY = Symbol.for("torbox-admin.signing-urls");
const signingGlobals = globalThis as {
[SIGNING_KEY]?: Map<string, Promise<string>>;
};
const signing = (signingGlobals[SIGNING_KEY] ??= new Map());
type RequestDownloadUrlOptions = {
/** 不用缓存、重新签发:缓存的直链被下载节点拒绝时使用 */
fresh?: boolean;
};
/**
* 向 TorBox 换取临时直链(仅服务端使用,不下发给客户端)。
* requestdl 额度很紧,直链在 SQLite 里缓存 TORBOX_DOWNLOAD_URL_TTL_MS,重启后仍可复用
*/
export const requestDownloadUrl = async (
type: SourceType,
id: string,
fileId: string,
{ fresh = false }: RequestDownloadUrlOptions = {}
): Promise<string> => {
if (isMockMode()) throw new Error(MOCK_PLAY_ERROR);
const key = `${type}/${id}/${fileId}`;
if (!fresh) {
const cached = getCachedUrl(key, Date.now());
if (cached) return cached;
}
// 签发中的一定是新直链,fresh 也可以直接共用
let pending = signing.get(key);
if (!pending) {
pending = signDownloadUrl(type, id, fileId)
.then((url) => {
saveUrl(key, url, Date.now());
return url;
})
.finally(() => signing.delete(key));
signing.set(key, pending);
}
return pending;
};
/**
* 创建 TorBox 网页转码流(Pro 账号功能),返回 HLS 播放列表地址。
* 地址的 token 参数就是 API Key,只在服务端使用,不下发给客户端。
+10
View File
@@ -0,0 +1,10 @@
// TorBox 限流状态 API 请求
import BaseClientService from "@/lib/axios";
import type { ApiResponse } from "@/types/api";
import type { TorboxLimitsResponse } from "@/types/torbox";
export const getTorboxLimits = async () => {
const { data } =
await BaseClientService.get<ApiResponse<TorboxLimitsResponse>>("/limits");
return data.data;
};
+18
View File
@@ -1,4 +1,6 @@
// TorBox 业务类型
import type { TorboxLimitId } from "@/constants/torbox-limits";
export type SourceType = "torrents" | "usenet" | "webdl";
export type LibraryFile = {
@@ -49,4 +51,20 @@ export type LibraryResponse = {
items: LibraryItem[];
/** 拉取失败的来源及原因 */
errors: Partial<Record<SourceType, string>>;
/** 有来源因限流没拉到时,多久(毫秒)后额度恢复;前端届时自动重新拉取 */
retryAfterMs?: number;
};
/** 当前受限的 TorBox 额度 */
export type TorboxLimitState = {
id: TorboxLimitId;
/** 操作名,如「获取播放地址」 */
label: string;
/** 多久(毫秒)后恢复 */
retryAfterMs: number;
};
export type TorboxLimitsResponse = {
/** 只含当前受限的额度,为空表示不受限 */
limits: TorboxLimitState[];
};