feat: 增加限流记录

This commit is contained in:
2026-10-06 21:17:35 -07:00
parent 54cecbf23f
commit 48d18c346e
18 changed files with 998 additions and 151 deletions
+6 -5
View File
@@ -65,15 +65,15 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx
├─ header.shrink-0 ← AppHeader,永远在顶部
└─ main.flex.min-h-0.flex-1.flex-col
└─ div.flex.min-h-0.flex-1.flex-col ← 页面
├─ 工具栏.shrink-0 ← 也不随内容滚动
├─ 工具栏.shrink-0 ← 也不随内容滚动(手机宽度下浮在滚动容器上方,见例外二)
└─ div.min-h-0.flex-1.overflow-y-auto ← 唯一的滚动容器
```
- 需要撑满的子项加 `flex-1 min-h-0`(`min-h-0` 不能省,否则内容会把容器撑破)。
- 推到右侧用 `ml-auto`,居中用 `items-center justify-center`。
- **例外一**:等宽卡片网格使用 grid(见 `FileGrid` 组件),列数随宽度 `grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5`,**一行最多 5 张卡片**,卡片宽度保持在约 190–240px。flex-wrap 做不到"等宽 + 末行不拉伸",grid 也不属于定位。
- **例外二·叠层**(装饰层与内容叠在一起,如登录页点阵画布、卡片准星、封面光斑)用单格 grid:父元素 `grid`,子元素 `col-start-1 row-start-1`,后写的在上层;装饰层加 `pointer-events-none`。需要移动时用 Motion 的 `x/y`(transform),transform 不属于定位。
- **例外三·分组标题吸顶**:资源库的分组标题行用原生 `sticky`(全站唯一),由浏览器合成,滚动时不跑 JS。虚拟列表把每个分组的可见行包进一个与整组等高的容器(未渲染部分用占位块撑出),标题以它为边界,到分组末尾时被下一组自然推出;`rangeExtractor` 保证当前分组的标题始终渲染。吸附时的底边线用 `stuck-divider`(scroll-state 容器查询);容器查询改不了容器自身,吸附时标题行两侧各外扩 6px 由 `useStuck`(IntersectionObserver,观察边界比吸附位置低 1px)驱动。
- **例外二·叠层**(装饰层与内容叠在一起,如登录页点阵画布、卡片准星、封面光斑)用单格 grid:父元素 `grid`,子元素 `col-start-1 row-start-1`,后写的在上层;装饰层加 `pointer-events-none`。需要移动时用 Motion 的 `x/y`(transform),transform 不属于定位。手机宽度下资源库工具栏也用这种叠层浮在滚动容器上方(`max-md:grid`,宽屏仍是 flex 纵向排列),收起时只做 transform,滚动容器尺寸不变;列表从工具栏下方开始(虚拟器 `paddingStart` 加上工具栏高度)。
- **例外三·分组标题吸顶**:资源库的分组标题行用原生 `sticky`(全站唯一),由浏览器合成,滚动时不跑 JS。虚拟列表把每个分组的可见行包进一个与整组等高的容器(未渲染部分用占位块撑出),标题以它为边界,到分组末尾时被下一组自然推出;`rangeExtractor` 保证当前分组的标题始终渲染。标题吸在工具栏露出部分的下沿:`top` 读滚动容器上的 `--toolbar-inset`,由工具栏位移的 MotionValue 逐帧换算,与工具栏同帧移动(宽屏为 0)。吸附时的底边线用 `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、Sheet、DropdownMenu、Tooltip 为 `z-50`,图片灯箱 `z-90`(`image-lightbox.tsx` 覆盖库默认的 9999),Toast 视口 `z-100`。通知必须盖过所有浮层,否则在弹窗内触发的 `message.*` 会被弹窗挡住;新增浮层组件不要超过 `z-90`。
@@ -90,7 +90,7 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx
- 默认保持 TorBox 返回的顺序:来源按 torrents → usenet → webdl 合并,资源内文件不重排。工具栏「排序」菜单(`DropdownMenuRadioGroup`)可选最新加入、最早加入、名称、体积最大、即将过期,比较函数集中在 `use-library.ts` 的 `LIBRARY_SORTERS`,缺字段的排在最后。
- 筛选或排序变化回到顶部;滚动快照(锚点行 + 偏移 + 已测量尺寸)存于 sessionStorage,刷新页面后按锚点恢复。
- 展开状态默认全部展开,**不持久化**,刷新后恢复全部展开。store 记录「基准状态 + 单独切换过的资源」,工具栏图标按钮一键全部收起 / 展开;全部切换或在吸顶条上收起后,把当前分组的标题放回视口顶部。
- 分组标题吸顶见第 5 节例外三;行组件 `memo`,滚动时只渲染新进入范围的行。键盘移动时虚拟器 `scrollPaddingStart`、Tab 聚焦时滚动容器 `scroll-pt-14` 都会留出吸顶标题的高度。
- 分组标题吸顶见第 5 节例外三;行组件 `memo`,滚动时只渲染新进入范围的行。键盘移动时虚拟器 `scrollPaddingStart`、Tab 聚焦时滚动容器的 `scroll-padding-top` 都会留出吸顶标题(3.5rem)与浮动工具栏的高度;滚动快照、收起后回到标题处都以工具栏下沿为视口顶部。
## 6. 组件选型
@@ -179,7 +179,7 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx
| --- | --- | --- |
| `src/utils/` | `scramble`、`grid-nav`、`layout`、`date`、`sort` | 纯函数,无 React、无业务 |
| `src/lib/dot-field.ts` | 画布点阵场引擎 | 不依赖 React;渲染循环用 Motion `frame`,涟漪时序用 `animate` |
| `src/hooks/` | `use-follow-highlight`、`use-spotlight`、`use-grid-keyboard-nav`、`use-hotkey` | 通用交互,无业务语义;只产出 MotionValue / 几何 |
| `src/hooks/` | `use-follow-highlight`、`use-spotlight`、`use-grid-keyboard-nav`、`use-hotkey`、`use-hide-on-scroll` | 通用交互,无业务语义;只产出 MotionValue / 几何 / 状态 |
| `src/components/` | `Reticle`、`DotField`、`ScrambleText`、`AnimatedNumber`、`TypeText`、`ImageLightbox`、`VideoPlayer` | 通用展示组件,外观在这里 |
| 页面组件 | `library-list`、`file-card`、`login-stage` 等 | 只做接线:传选择器、参数与回调 |
@@ -208,5 +208,6 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx
| 视频播放列表 | 同一资源的视频超过一个时始终显示,无边框,标题行只有「播放列表 3 / 12」:宽屏(`≥ lg`)在右侧占满高度,窄屏在信息行下方占满剩余高度(最低 12rem,更矮时整块滚动),内容多时只滚动列表;点击切换,播完自动接下一个(最后一个不循环),当前项高亮并滚入视野;切换同时转移封面的「上次播放」标记。Video.js v10 没有播放列表功能(官方列为已知缺口),所以列表与接续逻辑由项目自己实现 | `VideoPlaylist` + `LibraryVideoDialog`(`VideoPlayer` 的 `onEnded`);顺序与资源列表一致,受类型、搜索筛选影响 |
| 图片预览 | 点击图片封面或「预览」打开灯箱:左右键 / 按钮 / 滑动在同一分组内循环切换;滚轮、双击、双指或工具栏按钮缩放;Esc、点击空白处或下拉关闭,焦点回到打开它的按钮 | `ImageLightbox`;配色通过 `--yarl__*` 变量映射到主题变量,图标换成 lucide,加载超过 0.4s 才淡入转圈;减弱动态效果由灯箱自行处理 |
| 手机顶栏菜单 | 宽度 `< md` 时顶栏右侧的播放列表、退出登录与工具栏的来源 / 下载状态 / 类型三组筛选收进右上角汉堡按钮;点击后菜单从右侧滑出(高度撑满,右上角关闭):筛选按等宽网格排列并带「重置」(只清这三项,保留关键词),下面是 M3U 的「复制 M3U 地址」(手机上没有 PotPlayer,不提供「用 PotPlayer 打开」),退出登录固定在底部;三项中任一项不是「全部」时汉堡图标右上角弹出翠绿提示点,读屏名为「菜单(已启用筛选)」 | `MobileMenu`(`Sheet`)+ `LibraryFilters layout="stacked"`(与工具栏共用);提示点用单格 grid 叠层 + Motion `scale`,参数见 `INDICATOR_DOT`;来源计数只在菜单打开时计算 |
| 手机工具栏 | 宽度 `< md` 时工具栏浮在列表上方,向下浏览时上移滑进顶栏下方、向上浏览或回到顶部附近滑回;列表内容只跟手指走,不会被工具栏推动;吸顶标题贴着工具栏下沿一起移动;同一方向累计滚动过阈值才切换,正在输入搜索时保持展开,收起后 `inert` 不可聚焦 | `useHideOnScroll`(滚动容器上监听方向,只在切换时渲染)+ 单格 grid 叠层 + Motion `y`,参数见 `TOOLBAR_COLLAPSE`;不改高度:改高度会让滚动容器每帧重新布局,内容被额外推开,动画也只能在主线程跑 |
| 键盘 | `/` 聚焦搜索,搜索框内 Esc 先清空再失焦;方向键在卡片间移动(跨分组、自动滚动),Enter 执行主操作 | `useHotkey`、`useGridKeyboardNav` + `utils/grid-nav` |
@@ -0,0 +1,17 @@
CREATE TABLE `torbox_request_log` (
`id` integer PRIMARY KEY AUTOINCREMENT,
`at` integer NOT NULL,
`endpoint` text,
`cdn_host` text,
`sent` integer NOT NULL,
`status` integer,
`duration_ms` integer,
`queued_ms` integer,
`limited_by` text,
`rule` text,
`retry_after_ms` integer,
`usage` text,
`detail` text
);
--> statement-breakpoint
CREATE INDEX `torbox_request_log_at_idx` ON `torbox_request_log` (`at`);
@@ -0,0 +1,305 @@
{
"version": "7",
"dialect": "sqlite",
"id": "44df3286-b30a-47cf-96f5-18aaf9f0d50c",
"prevIds": [
"2ad8ae3a-f804-4eff-ba8d-b9deeafe2dd9"
],
"ddl": [
{
"name": "torbox_download_urls",
"entityType": "tables"
},
{
"name": "torbox_rate_hits",
"entityType": "tables"
},
{
"name": "torbox_rate_pauses",
"entityType": "tables"
},
{
"name": "torbox_request_log",
"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"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "at",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "endpoint",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "cdn_host",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "sent",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "status",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "duration_ms",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "queued_ms",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "limited_by",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "rule",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "retry_after_ms",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "usage",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "detail",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"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": [
"id"
],
"nameExplicit": false,
"name": "torbox_request_log_pk",
"table": "torbox_request_log",
"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"
},
{
"columns": [
{
"value": "at",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "torbox_request_log_at_idx",
"entityType": "indexes",
"table": "torbox_request_log"
}
],
"renames": []
}
+76 -25
View File
@@ -18,6 +18,7 @@ import {
type VirtualItem,
type Virtualizer
} from "@tanstack/react-virtual";
import type { MotionValue } from "motion/react";
import { Reticle } from "@/components/reticle";
import { RETICLE } from "@/constants/motion";
@@ -76,14 +77,22 @@ type LibraryListProps = {
items: LibraryItemView[];
notices: [SourceType, string][];
token: string;
/**
* 浮在列表上方的工具栏完全展开时盖住的高度(手机宽度),列表从它下面开始;宽屏为 0。
* 吸顶标题的 top 读滚动容器上的 --toolbar-inset(工具栏当前露出的高度)
*/
toolbarInset: number;
/** 工具栏位移:展开为 0,收起为 -toolbarInset;露出高度 = max(0, toolbarInset + toolbarY) */
toolbarY: MotionValue<number>;
};
/** 记录首个可见行与偏移,供刷新页面后按锚点恢复 */
/** 记录视口顶部(工具栏下沿)的行与偏移,供刷新页面后按锚点恢复 */
const takeScrollSnapshot = (
instance: RowVirtualizer,
signature: string
signature: string,
visibleInset: number
): LibraryScrollSnapshot | null => {
const offset = instance.scrollOffset ?? 0;
const offset = (instance.scrollOffset ?? 0) + visibleInset;
const anchor = instance.getVirtualItemForOffset(offset);
if (!anchor) return null;
return {
@@ -94,19 +103,24 @@ const takeScrollSnapshot = (
};
};
/** 按锚点行在当前数据中的位置换算恢复偏移;锚点已不存在则回到顶部 */
/**
* 按锚点行在当前数据中的位置换算恢复偏移,锚点回到视口顶部(visibleInset 为工具栏露出的高度);
* 锚点已不存在则回到顶部
*/
const getRestoreOffset = (
rows: LibraryRow[],
snapshot: LibraryScrollSnapshot,
estimate: (index: number) => number
estimate: (index: number) => number,
paddingStart: number,
visibleInset: number
) => {
const sizes = new Map(
snapshot.measurements.map((item) => [String(item.key), item.size])
);
let offset = PADDING_START;
let offset = paddingStart;
for (let index = 0; index < rows.length; index += 1) {
if (rows[index].key === snapshot.anchorKey)
return Math.max(0, offset + snapshot.anchorDelta);
return Math.max(0, offset + snapshot.anchorDelta - visibleInset);
offset += sizes.get(rows[index].key) ?? estimate(index);
}
return 0;
@@ -114,22 +128,25 @@ const getRestoreOffset = (
/**
* 行自身的留白计入测量高度,因此虚拟器 gap 为 0。
* 分组标题用原生 sticky 吸顶(由浏览器合成,滚动时不跑 JS):非首个标题有 20px 上留白,
* 用 -top-3 藏掉 12px,吸附时与首个标题一样露出 8px 上留白。
* 分组标题用原生 sticky 吸顶(由浏览器合成,滚动时不跑 JS),吸在工具栏露出部分的下沿
* (--toolbar-inset,未设置为 0):非首个标题有 20px 上留白,再往上藏掉 12px,
* 吸附时与首个标题一样露出 8px 上留白。
*/
const rowClassName = (row: LibraryRow, stuck: boolean) => {
if (row.type === "notice") return "pb-2";
if (row.type === "header")
return cn(
"stuck-divider sticky bg-background",
row.first ? "top-0 pt-2" : "-top-3 pt-5",
row.first
? "top-[var(--toolbar-inset,0px)] pt-2"
: "top-[calc(var(--toolbar-inset,0px)-0.75rem)] pt-5",
// 吸附后两侧各外扩 6px,内边距补回,内容位置不变
stuck && "-mx-1.5 px-1.5 z-10"
);
return "pb-3";
};
/** 标题行 sticky 的 top(px),与 rowClassName 中的 top-0 / -top-3 对应 */
/** 工具栏露出高度为 0 时标题行 sticky 的 top(px),与 rowClassName 中的 0 / -0.75rem 对应 */
const getStickyTop = (row: LibraryRow) =>
row.type === "header" && !row.first ? -12 : 0;
@@ -145,6 +162,8 @@ type RowViewProps = {
token: string;
/** 本行里最近播放的视频(文件 id),没有则为 null */
playedFileId: number | null;
/** 工具栏完全展开时盖住的高度,计入吸附判断的边界 */
toolbarInset: number;
scrollRef: React.RefObject<HTMLDivElement | null>;
measure: (element: HTMLDivElement | null) => void;
onToggle: (row: HeaderRow, index: number) => void;
@@ -158,14 +177,17 @@ const RowView = memo(function RowView({
columns,
token,
playedFileId,
toolbarInset,
scrollRef,
measure,
onToggle,
onOpen
}: RowViewProps) {
// 边界按工具栏展开时算:收起后吸在更高处的标题仍在边界之上;刚要吸附的标题会提前判为吸附,
// 而吸附样式(两侧外扩、层级)在标题未吸附时看不出差别
const [stuckRef, stuck] = useStuck<HTMLDivElement>(
scrollRef,
getStickyTop(row),
getStickyTop(row) + toolbarInset,
row.type === "header"
);
const ref = useCallback(
@@ -238,6 +260,8 @@ const VirtualRows = ({
items,
notices,
token,
toolbarInset,
toolbarY,
width
}: LibraryListProps & { width: number }) => {
const columns = getColumnCount(width);
@@ -290,6 +314,13 @@ const VirtualRows = ({
[groups]
);
// 列表从工具栏下方开始;滚动定位以工具栏下沿为视口顶部
const paddingStart = PADDING_START + toolbarInset;
const getVisibleInset = useCallback(
() => Math.max(0, toolbarInset + toolbarY.get()),
[toolbarInset, toolbarY]
);
// 只在挂载时读取一次快照,筛选条件一致才恢复
const [restore] = useState(() => {
const snapshot = useLibraryViewStore.getState().snapshot;
@@ -307,18 +338,30 @@ const VirtualRows = ({
estimateSize,
rangeExtractor,
overscan: OVERSCAN,
paddingStart: PADDING_START,
paddingStart,
paddingEnd: PADDING_END,
scrollPaddingStart: STICKY_HEADER_SPACE,
scrollPaddingStart: STICKY_HEADER_SPACE + toolbarInset,
anchorTo: atTop ? "start" : "end",
initialMeasurementsCache: restore?.measurements,
initialOffset: () =>
restore ? getRestoreOffset(rows, restore, estimateSize) : 0,
restore
? getRestoreOffset(
rows,
restore,
estimateSize,
paddingStart,
getVisibleInset()
)
: 0,
onChange: (instance) => {
setAtTop((instance.scrollOffset ?? 0) <= 1);
// 每次滚动停止时保存快照
if (wasScrolling.current && !instance.isScrolling) {
const snapshot = takeScrollSnapshot(instance, signature);
const snapshot = takeScrollSnapshot(
instance,
signature,
getVisibleInset()
);
if (snapshot) saveSnapshot(snapshot);
}
wasScrolling.current = instance.isScrolling;
@@ -333,10 +376,10 @@ const VirtualRows = ({
virtualizer.scrollToOffset(0);
}, [signature, virtualizer]);
/** 视口顶部所在资源分组的标题行 */
/** 视口顶部(工具栏下沿)所在资源分组的标题行 */
const getTopHeader = () => {
const item = virtualizer.getVirtualItemForOffset(
virtualizer.scrollOffset ?? 0
(virtualizer.scrollOffset ?? 0) + getVisibleInset()
);
const group = item && groups[item.index];
const row = group && rows[group.header];
@@ -353,8 +396,8 @@ const VirtualRows = ({
const row = index === undefined ? null : rows[index];
if (index === undefined || row?.type !== "header") return;
const start = virtualizer.measurementsCache[index]?.start ?? 0;
virtualizer.scrollToOffset(row.first ? 0 : start);
}, [rows, indexByKey, virtualizer]);
virtualizer.scrollToOffset(row.first ? 0 : start - getVisibleInset());
}, [rows, indexByKey, virtualizer, getVisibleInset]);
// 「全部展开 / 全部收起」:在重新渲染前记下当前分组;停在顶部时不处理
const onBulkToggle = useEffectEvent(() => {
@@ -369,15 +412,18 @@ const VirtualRows = ({
[]
);
// 标题处于吸顶状态(原位置已滚出视口上方)时收起,收起后回到该分组的标题处
// 标题处于吸顶状态(原位置已滚到工具栏下沿之上)时收起,收起后回到该分组的标题处
const onToggleHeader = useCallback(
(row: HeaderRow, index: number) => {
const start = virtualizer.measurementsCache[index]?.start ?? 0;
if (row.expanded && start < (virtualizer.scrollOffset ?? 0))
if (
row.expanded &&
start < (virtualizer.scrollOffset ?? 0) + getVisibleInset()
)
pendingJump.current = row.itemKey;
toggleItem(row.itemKey);
},
[virtualizer, toggleItem]
[virtualizer, toggleItem, getVisibleInset]
);
// 图片灯箱与视频弹窗各只渲染一个,不放在卡片里:
@@ -400,10 +446,14 @@ const VirtualRows = ({
// 卸载时(如列表被空状态替换)补存一次快照
useEffect(
() => () => {
const snapshot = takeScrollSnapshot(virtualizer, lastSignature.current);
const snapshot = takeScrollSnapshot(
virtualizer,
lastSignature.current,
getVisibleInset()
);
if (snapshot) saveSnapshot(snapshot);
},
[virtualizer, saveSnapshot]
[virtualizer, saveSnapshot, getVisibleInset]
);
// 交互层:都挂在同一个容器上做事件委托,彼此独立,删掉一处调用即可拆掉
@@ -453,6 +503,7 @@ const VirtualRows = ({
? lastPlayed.fileId
: null
}
toolbarInset={toolbarInset}
scrollRef={scrollRef}
measure={virtualizer.measureElement}
onToggle={onToggleHeader}
+6 -1
View File
@@ -177,6 +177,8 @@ type LibraryToolbarProps = {
anyExpanded: boolean;
isFetching: boolean;
onRefresh: () => void;
/** 搜索框获得 / 失去焦点:手机宽度下输入时工具栏保持展开 */
onSearchFocusChange?: (focused: boolean) => void;
};
export const LibraryToolbar = ({
@@ -184,7 +186,8 @@ export const LibraryToolbar = ({
summary,
anyExpanded,
isFetching,
onRefresh
onRefresh,
onSearchFocusChange
}: LibraryToolbarProps) => {
const keyword = useLibraryFilterStore((state) => state.keyword);
const setKeyword = useLibraryFilterStore((state) => state.setKeyword);
@@ -209,6 +212,8 @@ export const LibraryToolbar = ({
aria-keyshortcuts="/"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
onFocus={() => onSearchFocusChange?.(true)}
onBlur={() => onSearchFocusChange?.(false)}
onKeyDown={(event) => {
if (event.key !== "Escape") return;
if (keyword) setKeyword("");
+65 -7
View File
@@ -1,8 +1,15 @@
// 资源库视图(数据获取与状态分发)
"use client";
import { useMemo, useRef } from "react";
import { useMemo, useRef, useState } from "react";
import { FolderOpenIcon, SearchXIcon, TriangleAlertIcon } from "lucide-react";
import {
motion,
type MotionStyle,
useMotionValue,
useReducedMotion,
useTransform
} from "motion/react";
import {
Alert,
@@ -19,7 +26,11 @@ import {
EmptyMedia,
EmptyTitle
} from "@/components/ui/empty";
import { TOOLBAR_COLLAPSE } from "@/constants/motion";
import { useElementHeight } from "@/hooks/use-element-height";
import { useHideOnScroll } from "@/hooks/use-hide-on-scroll";
import { useFilteredLibrary, useRefreshLibrary } from "@/hooks/use-library";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useLibraryFilterStore } from "@/stores/library-filter-store";
import {
getExpandedChecker,
@@ -46,6 +57,28 @@ export const LibraryView = ({ token }: { token: string }) => {
}, [items, allExpanded, toggled]);
// 滚动容器由页面持有,虚拟列表只负责计算渲染哪些行
const scrollRef = useRef<HTMLDivElement>(null);
// 手机宽度下工具栏浮在列表上方:向下浏览时上移收起,向上浏览时滑回,列表不重新布局;
// 正在输入搜索时保持展开
const mobile = useMediaQuery("(width < 48rem)");
const [searchFocused, setSearchFocused] = useState(false);
const toolbarHidden = useHideOnScroll(scrollRef, {
enabled: mobile && !searchFocused,
threshold: TOOLBAR_COLLAPSE.threshold,
topOffset: TOOLBAR_COLLAPSE.topOffset
});
const reduced = useReducedMotion();
const [toolbarRef, toolbarHeight] = useElementHeight<HTMLDivElement>();
/** 工具栏完全展开时盖住的列表顶部高度;宽屏工具栏不浮在列表上,为 0 */
const toolbarInset = mobile ? toolbarHeight : 0;
/** 工具栏的位移:展开为 0,收起为 -toolbarHeight */
const toolbarY = useMotionValue(0);
// 吸顶标题贴在工具栏露出部分的下沿,与工具栏在同一帧移动;
// 收起状态下切到宽屏时 toolbarY 还在回到 0 的途中,露出高度不小于 0
const stickyInset = useTransform(
toolbarY,
(y) => `${Math.max(0, toolbarInset + y)}px`
);
const notices = useMemo(
() => Object.entries(query.data?.errors ?? {}) as [SourceType, string][],
[query.data]
@@ -117,21 +150,44 @@ export const LibraryView = ({ token }: { token: string }) => {
}
return (
<div className="flex min-h-0 flex-1 flex-col">
// 手机宽度下工具栏与滚动容器叠放在同一格(单格 grid),工具栏在上层;
// 收起时用 transform 上移并被这一层裁掉,看起来滑进顶栏下方
<div className="flex min-h-0 flex-1 flex-col max-md:grid max-md:grid-cols-1 max-md:grid-rows-1 max-md:overflow-hidden max-md:*:col-start-1 max-md:*:row-start-1">
<motion.div
ref={toolbarRef}
className="shrink-0 bg-background max-md:z-20 max-md:self-start"
style={{ y: toolbarY }}
initial={false}
animate={{ y: toolbarHidden ? -toolbarHeight : 0 }}
transition={reduced ? { duration: 0 } : TOOLBAR_COLLAPSE.transition}
inert={toolbarHidden}
>
<LibraryToolbar
sourceCounts={sourceCounts}
summary={summary}
anyExpanded={anyExpanded}
isFetching={query.isFetching}
onRefresh={refresh}
onSearchFocusChange={setSearchFocused}
/>
{/* scroll-pt-14:Tab 聚焦时浏览器自动滚动会避开资源列表的吸顶标题条 */}
<div
</motion.div>
{/* Tab 聚焦时浏览器自动滚动会避开工具栏与资源列表的吸顶标题条(3.5rem) */}
<motion.div
ref={scrollRef}
className="min-h-0 flex-1 scroll-pt-14 overflow-y-auto"
className="min-h-0 flex-1 overflow-y-auto"
style={
{
// MotionStyle 未声明 CSS 变量,运行时按 MotionValue 逐帧写入
"--toolbar-inset": stickyInset,
scrollPaddingTop: `calc(3.5rem + ${toolbarInset}px)`
} as MotionStyle
}
>
{content ? (
<div className="mx-auto flex w-full sm:max-w-[1920px] flex-col gap-8 p-4 md:p-6">
<div
className="mx-auto flex w-full sm:max-w-[1920px] flex-col gap-8 p-4 md:p-6"
style={{ marginTop: toolbarInset }}
>
{content}
</div>
) : (
@@ -140,9 +196,11 @@ export const LibraryView = ({ token }: { token: string }) => {
items={items}
notices={notices}
token={token}
toolbarInset={toolbarInset}
toolbarY={toolbarY}
/>
)}
</div>
</motion.div>
</div>
);
};
+18 -6
View File
@@ -4,6 +4,7 @@ import { guard } from "@/server/auth";
import { createLogger, mediaRef } from "@/server/logger";
import { jsonError, jsonUpstreamError, withApi } from "@/server/response";
import { createStream, isSourceType } from "@/server/torbox";
import { emitTorboxEvent } from "@/server/torbox-events";
import {
getRateLimitHeaders,
getResponseHeaders
@@ -39,18 +40,29 @@ export const GET = withApi(async (req: NextRequest, { params }: Ctx) => {
cache: "no-store"
});
if (!upstream.ok) {
const limited = upstream.status === 429;
const cdnHost = new URL(playlistUrl).host;
// 节点限流的规则尚不明确,429 时记下全部响应头
const upstreamHeaders = limited
? getResponseHeaders(upstream.headers)
: getRateLimitHeaders(upstream.headers);
log.warn(
{
...media,
cdnHost,
...(limited ? { limitedBy: "cdn" } : {}),
upstreamStatus: upstream.status,
// 节点限流的规则尚不明确,429 时记下全部响应头
headers:
upstream.status === 429
? getResponseHeaders(upstream.headers)
: getRateLimitHeaders(upstream.headers)
headers: upstreamHeaders
},
"转码列表请求失败"
limited ? "转码节点限流:返回 429" : "转码列表请求失败"
);
emitTorboxEvent({
cdnHost,
sent: true,
status: upstream.status,
...(limited ? { limitedBy: "cdn" as const } : {}),
detail: { ...media, hls: true, headers: upstreamHeaders }
});
return jsonError(502, `TorBox 播放列表请求失败(${upstream.status})`);
}
+6 -3
View File
@@ -16,13 +16,16 @@ export const GET = withApi(async (req: NextRequest) => {
// ?fresh=1:用户手动刷新,绕过 TorBox 列表缓存
const bypassCache = req.nextUrl.searchParams.get("fresh") === "1";
const data = await getLibrary({ bypassCache });
const failedSources = Object.keys(data.errors);
log[failedSources.length ? "warn" : "info"](
const failed = Object.keys(data.errors).length > 0;
log[failed ? "warn" : "info"](
{
bypassCache,
itemCount: data.items.length,
fileCount: data.items.reduce((sum, item) => sum + item.files.length, 0),
failedSources
// 各来源失败原因;因限流失败时另带多久后恢复,限流详情见 torbox-limiter 的日志
...(failed
? { errors: data.errors, retryAfterMs: data.retryAfterMs }
: {})
},
"资源列表"
);
@@ -5,7 +5,12 @@ import { isMockMode } from "@/server/env";
import { createLogger, mediaRef } from "@/server/logger";
import { getMockImage } from "@/server/mock-library";
import { jsonError, jsonUpstreamError, withApi } from "@/server/response";
import { isSourceType, requestDownloadUrl } from "@/server/torbox";
import {
isSourceType,
requestDownloadUrl,
type DownloadUrlFrom
} from "@/server/torbox";
import { emitTorboxEvent } from "@/server/torbox-events";
import {
getRateLimitHeaders,
getResponseHeaders
@@ -29,8 +34,17 @@ const PASS_HEADERS = [
// 直链的下载节点偶尔会拒绝直链(400 Invalid Presigned Token),重新签发一条即可
const MAX_ATTEMPTS = 3;
/** 下载节点的错误响应体只记录开头 */
const MAX_LOGGED_BODY = 500;
const KIND_LABEL = { video: "播放", image: "图片", other: "文件" } as const;
const URL_FROM_LABEL: Record<DownloadUrlFrom, string> = {
cache: "直链:使用缓存",
shared: "直链:等待同一文件正在进行的签发",
signed: "直链:向 TorBox 签发"
};
const log = createLogger("stream");
type Ctx = {
@@ -87,12 +101,28 @@ const proxy = async (
let upstream: Response;
/** 下载节点:实测同一节点并发超过 20 会 429,出错时记录便于核对 */
let cdnHost = "";
/** 本次用的直链:来源与已用时长,出错时一并记录 */
let link: { attempt?: number; from?: DownloadUrlFrom; ageSec?: number } =
{};
for (let attempt = 1; ; attempt += 1) {
// 首次用缓存的直链,被拒绝后重新签发
const url = await requestDownloadUrl(type, id, fileId, {
fresh: attempt > 1
});
const { url, signedAt, from } = await requestDownloadUrl(
type,
id,
fileId,
{ fresh: attempt > 1 }
);
cdnHost = new URL(url).host;
link = {
attempt,
from,
ageSec: Math.round((Date.now() - signedAt) / 1000)
};
// 拖动进度的 Range 请求大多命中缓存,只记 debug;向 TorBox 签发的始终记录
log[isStart || from === "signed" ? "info" : "debug"](
{ ...media, method, cdnHost, ...link },
URL_FROM_LABEL[from]
);
upstream = await fetch(url, {
method,
headers: range ? { Range: range } : undefined,
@@ -100,35 +130,57 @@ const proxy = async (
cache: "no-store"
});
if (upstream.status !== 400 || attempt >= MAX_ATTEMPTS) break;
log.warn(
{
...media,
method,
attempt,
upstreamStatus: upstream.status,
contentType: upstream.headers.get("content-type")
},
"直链被拒绝,重新获取"
// 被拒绝的原因在响应体里(如 Invalid Presigned Token),只取开头
const body = (await upstream.text().catch(() => "")).slice(
0,
MAX_LOGGED_BODY
);
await upstream.body?.cancel();
}
if (!upstream.ok)
log.warn(
{
const rejected = {
...media,
method,
range,
cdnHost,
...link,
upstreamStatus: upstream.status,
contentType: upstream.headers.get("content-type"),
body
};
log.warn(rejected, "直链被拒绝,重新获取");
emitTorboxEvent({
cdnHost,
sent: true,
status: upstream.status,
detail: rejected
});
}
if (!upstream.ok) {
const limited = upstream.status === 429;
// 节点限流的规则尚不明确,429 时记下全部响应头
headers:
upstream.status === 429
const failed = {
...media,
method,
range,
cdnHost,
...link,
upstreamStatus: upstream.status,
contentType: upstream.headers.get("content-type"),
headers: limited
? getResponseHeaders(upstream.headers)
: getRateLimitHeaders(upstream.headers)
},
"上游返回异常"
};
log.warn(
{ ...failed, ...(limited ? { limitedBy: "cdn" } : {}) },
limited ? "下载节点限流:返回 429" : "上游返回异常"
);
emitTorboxEvent({
cdnHost,
sent: true,
status: upstream.status,
...(limited ? { limitedBy: "cdn" as const } : {}),
detail: failed
});
}
const headers = new Headers({ "Cache-Control": "no-store" });
for (const name of PASS_HEADERS) {
+9
View File
@@ -149,6 +149,15 @@ export const IMAGE_LOADING = {
sweep: { duration: 1.4, ease: "linear", repeat: Infinity, repeatDelay: 0.2 }
} as const;
/** 手机宽度下的资源库工具栏:向下浏览时收起,向上浏览时展开 */
export const TOOLBAR_COLLAPSE = {
/** 同一方向累计滚动超过这个距离(px)才切换 */
threshold: 10,
/** 距顶部不足这个距离(px)时始终展开 */
topOffset: 64,
transition: { duration: 0.24, ease: [0.22, 1, 0.36, 1] }
} as const;
/** 手机顶栏菜单按钮上的筛选提示点:出现时弹出,消失时收回 */
export const INDICATOR_DOT = {
transition: { type: "spring", stiffness: 640, damping: 22 }
+19
View File
@@ -0,0 +1,19 @@
// 监听元素高度的 hook
import { useCallback, useState } from "react";
/** 元素边框盒高度(不受 transform 影响),挂载时同步测量,之后随 ResizeObserver 更新 */
export const useElementHeight = <T extends HTMLElement>() => {
const [height, setHeight] = useState(0);
const ref = useCallback((node: T | null) => {
if (!node) return;
const observer = new ResizeObserver(([entry]) =>
setHeight(entry.borderBoxSize[0].blockSize)
);
observer.observe(node);
setHeight(node.offsetHeight);
return () => observer.disconnect();
}, []);
return [ref, height] as const;
};
+66
View File
@@ -0,0 +1,66 @@
// 向下滚动时隐藏、向上滚动时显示的 hook
import { useEffect, useState, type RefObject } from "react";
type HideOnScrollOptions = {
enabled?: boolean;
/** 同一方向累计滚动超过这个距离(px)才切换,过滤手指的细微抖动 */
threshold: number;
/** 距顶部不足这个距离(px)时始终显示 */
topOffset: number;
};
/**
* 容器向下滚动时返回 true(隐藏),向上滚动或回到顶部附近时返回 false;只在切换时触发渲染。
* 被隐藏的元素应浮在容器上方、用 transform 移走,不要改变容器尺寸:
* 否则收起时浏览器会修正 scrollTop,被误判为向上滚动。
*/
export const useHideOnScroll = (
containerRef: RefObject<HTMLElement | null>,
{ enabled = true, threshold, topOffset }: HideOnScrollOptions
) => {
const [hidden, setHidden] = useState(false);
useEffect(() => {
const node = containerRef.current;
if (!enabled || !node) return;
let current = false;
const update = (next: boolean) => {
if (next === current) return;
current = next;
setHidden(next);
};
// distance 为当前方向的累计距离,向下为正
let lastTop = node.scrollTop;
let distance = 0;
const onScroll = () => {
// iOS 回弹时 scrollTop 会越过两端,按边界计算
const top = Math.min(
Math.max(node.scrollTop, 0),
node.scrollHeight - node.clientHeight
);
const delta = top - lastTop;
lastTop = top;
if (top <= topOffset) {
distance = 0;
update(false);
return;
}
if (delta === 0) return;
distance =
Math.sign(delta) === Math.sign(distance) ? distance + delta : delta;
if (Math.abs(distance) >= threshold) update(distance > 0);
};
node.addEventListener("scroll", onScroll, { passive: true });
return () => {
node.removeEventListener("scroll", onScroll);
setHidden(false);
};
}, [containerRef, enabled, threshold, topOffset]);
return hidden;
};
+4 -1
View File
@@ -1,4 +1,4 @@
// 服务端启动钩子(环境变量校验、数据库迁移)
// 服务端启动钩子(环境变量校验、数据库迁移、TorBox 请求统计)
export async function register() {
// 仅在 Node.js 服务端运行时校验:未配置 TorBox 时进入演示模式,配置不安全时终止启动
if (process.env.NEXT_RUNTIME === "nodejs") {
@@ -7,5 +7,8 @@ export async function register() {
// 迁移失败时终止启动,不带着缺表的数据库运行
const { runMigrations } = await import("@/server/db/migrate");
runMigrations();
// 开发期统计 TorBox 请求与限流(写入 torbox_request_log),额度确定后删掉这两行
const { recordTorboxStats } = await import("@/server/torbox-stats");
recordTorboxStats();
}
}
+26
View File
@@ -30,3 +30,29 @@ export const torboxRatePauses = sqliteTable("torbox_rate_pauses", {
/** 毫秒时间戳 */
until: integer().notNull()
});
/**
* 开发期统计(src/server/torbox-stats.ts 写入):每次请求 TorBox(含被本地拒绝的)、下载节点每次返回错误各记一行,
* 用来校准 TORBOX_LIMITS。字段含义见 TorboxEvent(src/server/torbox-events.ts)
*/
export const torboxRequestLog = sqliteTable(
"torbox_request_log",
{
id: integer().primaryKey({ autoIncrement: true }),
/** 毫秒时间戳 */
at: integer().notNull(),
endpoint: text(),
cdnHost: text("cdn_host"),
sent: integer({ mode: "boolean" }).notNull(),
status: integer(),
durationMs: integer("duration_ms"),
queuedMs: integer("queued_ms"),
/** local / torbox / torbox-pause / cdn,正常请求为空 */
limitedBy: text("limited_by"),
rule: text(),
retryAfterMs: integer("retry_after_ms"),
usage: text({ mode: "json" }).$type<Record<string, unknown>>(),
detail: text({ mode: "json" }).$type<Record<string, unknown>>()
},
(table) => [index("torbox_request_log_at_idx").on(table.at)]
);
+57
View File
@@ -0,0 +1,57 @@
// TorBox 请求事件:每次请求 TorBox(含被本地拒绝的)、下载节点返回错误(429、直链被拒绝的 400 等)都在这里发出一条事件。
// 本模块只负责分发,谁需要谁订阅(如 torbox-stats 统计入库);没有订阅方时什么也不做
import type { TorboxLimitId } from "@/constants/torbox-limits";
import { createLogger } from "@/server/logger";
import type { TorboxLimitedBy } from "@/server/torbox-limiter";
const log = createLogger("torbox-events");
export type TorboxEvent = {
/** 毫秒时间戳 */
at: number;
/** TorBox 接口路径,如 /torrents/requestdl;下载节点的事件为空 */
endpoint?: string;
/** 下载节点主机名 */
cdnHost?: string;
/** 是否真的发出了请求:本地拒绝的为 false */
sent: boolean;
/** HTTP 状态码;超时、网络错误或未发出时为空 */
status?: number;
durationMs?: number;
/** 在本地限流器里排队的时间(等额度 + 等并发名额) */
queuedMs?: number;
/** 被谁限流,正常请求为空 */
limitedBy?: TorboxLimitedBy | "cdn";
/** 受限的额度 */
rule?: TorboxLimitId;
retryAfterMs?: number;
/** 各额度当时的用量 */
usage?: Record<string, unknown>;
/** 响应头、响应体、错误等其他信息 */
detail?: Record<string, unknown>;
};
type Listener = (event: TorboxEvent) => void;
// 挂在 globalThis 上:订阅在 instrumentation 里完成,发出事件的是各路由,打包后不是同一个模块实例
const LISTENERS_KEY = Symbol.for("torbox-admin.torbox-event-listeners");
const globals = globalThis as { [LISTENERS_KEY]?: Set<Listener> };
const listeners: Set<Listener> = (globals[LISTENERS_KEY] ??= new Set());
/** 订阅事件,返回取消订阅的函数 */
export const onTorboxEvent = (listener: Listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
/** 订阅方出错只记日志,不影响请求本身 */
export const emitTorboxEvent = (event: Omit<TorboxEvent, "at">) => {
const full = { ...event, at: Date.now() };
for (const listener of listeners) {
try {
listener(full);
} catch (error) {
log.error({ err: error }, "TorBox 事件处理失败");
}
}
};
+110 -27
View File
@@ -12,6 +12,7 @@ import {
import { db } from "@/server/db";
import { torboxRateHits, torboxRatePauses } from "@/server/db/schema";
import { createLogger } from "@/server/logger";
import { emitTorboxEvent } from "@/server/torbox-events";
import type { TorboxLimitState } from "@/types/torbox";
const log = createLogger("torbox-limiter");
@@ -25,11 +26,39 @@ const formatWait = (ms: number) =>
? `${Math.ceil(ms / 1000)} 秒`
: `${Math.ceil(ms / 60 / 1000)} 分钟`;
/**
* 被谁限流:
* - local:本地额度已用完,请求没有发出
* - torbox:TorBox 刚返回 429
* - torbox-pause:仍在 TorBox 429 之后的暂停期内,请求没有发出
*/
export type TorboxLimitedBy = "local" | "torbox" | "torbox-pause";
const LIMITED_BY_LABEL: Record<TorboxLimitedBy, string> = {
local: "本地限流",
torbox: "TorBox 限流",
"torbox-pause": "TorBox 限流暂停中"
};
/** 没有 match 的是所有接口共用的总额度,其余是单个接口的额度 */
const ruleName = (id: TorboxLimitId) => {
const rule = getRule(id);
return rule.match ? `「${rule.label}」额度` : "总额度";
};
/** 日志里标明是哪条额度 */
const describeRule = (id: TorboxLimitId) => ({
rule: id,
scope: getRule(id).match ? "endpoint" : "global",
ruleName: ruleName(id)
});
/** 额度暂时用尽:路由据此返回 429 和 Retry-After */
export class RateLimitError extends Error {
constructor(
readonly limitId: TorboxLimitId,
readonly retryAfterMs: number
readonly retryAfterMs: number,
readonly limitedBy: TorboxLimitedBy
) {
super(
`${getRule(limitId).label}过于频繁,请 ${formatWait(retryAfterMs)}后再试`
@@ -49,7 +78,7 @@ const getLimitIds = (endpoint: string) =>
/**
* 该额度窗口内已用次数,以及距可用还要多久:
* 暂停中取剩余暂停时间,窗口已满取足够多的记录滑出窗口的时间
* windowWaitMs 是窗口已满时足够多的记录滑出窗口的时间,pauseWaitMs 是 TorBox 429 暂停的剩余时间
*/
const getUsage = (id: TorboxLimitId, now: number) => {
const rule = getRule(id);
@@ -82,17 +111,34 @@ const getUsage = (id: TorboxLimitId, now: number) => {
.where(eq(torboxRatePauses.rule, id))
.get()?.until ?? 0;
return { used, waitMs: Math.max(pausedUntil - now, windowWait, 0) };
const windowWaitMs = Math.max(windowWait, 0);
const pauseWaitMs = Math.max(pausedUntil - now, 0);
return {
used,
windowWaitMs,
pauseWaitMs,
waitMs: Math.max(windowWaitMs, pauseWaitMs)
};
};
/** 各额度窗口内的用量,写进日志用来校准额度(实际限制尚不明确) */
/**
* 各额度窗口内的用量,写进日志用来校准额度(实际限制尚不明确);
* 正受限的额度另带窗口已满的等待时间、TorBox 429 暂停的剩余时间
*/
const describeUsage = (ids: TorboxLimitId[], now: number) =>
Object.fromEntries(
ids.map((id) => {
const { limit, windowMs } = getRule(id);
const { used, windowWaitMs, pauseWaitMs } = getUsage(id, now);
return [
id,
{ used: getUsage(id, now).used, limit, windowSec: windowMs / 1000 }
{
used,
limit,
windowSec: windowMs / 1000,
...(windowWaitMs > 0 ? { windowWaitMs } : {}),
...(pauseWaitMs > 0 ? { pauseWaitMs } : {})
}
];
})
);
@@ -105,16 +151,20 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
*/
const reserve = async (endpoint: string) => {
const ids = getLimitIds(endpoint);
const deadline = Date.now() + TORBOX_MAX_WAIT_MS;
const startedAt = Date.now();
const deadline = startedAt + TORBOX_MAX_WAIT_MS;
for (;;) {
const now = Date.now();
let waitMs = 0;
let blocking = ids[0];
let limitedBy: TorboxLimitedBy = "local";
for (const id of ids) {
const usage = getUsage(id, now);
if (usage.waitMs > waitMs) {
waitMs = usage.waitMs;
blocking = id;
limitedBy =
usage.pauseWaitMs >= usage.windowWaitMs ? "torbox-pause" : "local";
}
}
@@ -136,22 +186,30 @@ const reserve = async (endpoint: string) => {
return;
}
const fields = {
endpoint,
limitedBy,
...describeRule(blocking),
usage: describeUsage(ids, now)
};
const reason = `${LIMITED_BY_LABEL[limitedBy]}:${ruleName(blocking)}${limitedBy === "local" ? "已满" : ""}`;
if (now + waitMs > deadline) {
log.warn(
{
{ ...fields, retryAfterMs: waitMs },
`${reason},拒绝请求(未发往 TorBox)`
);
emitTorboxEvent({
endpoint,
sent: false,
queuedMs: now - startedAt,
limitedBy,
rule: blocking,
retryAfterMs: waitMs,
usage: describeUsage(ids, now)
},
"TorBox 额度用尽,拒绝请求"
);
throw new RateLimitError(blocking, waitMs);
usage: fields.usage
});
throw new RateLimitError(blocking, waitMs, limitedBy);
}
log.debug(
{ endpoint, rule: blocking, waitMs },
"TorBox 额度用尽,排队等待"
);
log.info({ ...fields, waitMs }, `${reason},排队 ${formatWait(waitMs)}`);
await sleep(waitMs);
}
};
@@ -182,15 +240,19 @@ const releaseSlot = () => {
else slots.active -= 1;
};
/** 先扣额度再占并发名额,task 结束(含读取响应体)后释放名额 */
/**
* 先扣额度再占并发名额,task 结束(含读取响应体)后释放名额。
* task 收到在本地排队的时间(等额度 + 等并发名额)
*/
export const limitTorbox = async <T>(
endpoint: string,
task: () => Promise<T>
task: (queuedMs: number) => Promise<T>
) => {
const startedAt = Date.now();
await reserve(endpoint);
await acquireSlot();
try {
return await task();
return await task(Date.now() - startedAt);
} finally {
releaseSlot();
}
@@ -222,12 +284,19 @@ export const getResponseHeaders = (headers: Headers) =>
/** 429 响应体只记录开头,足够看出 TorBox 的错误码与说明 */
const MAX_LOGGED_BODY = 2000;
/** 发出的这次请求:耗时与在本地排队的时间 */
type SentRequest = { durationMs: number; queuedMs: number };
/**
* TorBox 返回 429:暂停该接口最具体的那条额度(没有接口额度时暂停总预算),
* 等满 Retry-After 前不再发请求,提前重试会把锁定拖长。
* 实际限制尚不明确,响应头、响应体和本地用量全部记入日志。返回要抛出的错误
*/
export const pauseTorbox = async (endpoint: string, res: Response) => {
export const pauseTorbox = async (
endpoint: string,
res: Response,
request: SentRequest
) => {
const ids = getLimitIds(endpoint);
const id = ids.at(-1)!;
const retryAfter = res.headers.get("retry-after");
@@ -242,21 +311,35 @@ export const pauseTorbox = async (endpoint: string, res: Response) => {
})
.run();
const body = await res.text().catch(() => "");
const body = (await res.text().catch(() => "")).slice(0, MAX_LOGGED_BODY);
const headers = getResponseHeaders(res.headers);
const usage = describeUsage(ids, now);
log.warn(
{
endpoint,
status: res.status,
rule: id,
limitedBy: "torbox",
...describeRule(id),
retryAfter,
retryAfterMs,
headers: getResponseHeaders(res.headers),
body: body.slice(0, MAX_LOGGED_BODY),
usage: describeUsage(ids, now)
headers,
body,
usage
},
"TorBox 返回 429,暂停请求"
`${LIMITED_BY_LABEL.torbox}:返回 429,暂停${ruleName(id)} ${formatWait(retryAfterMs)}`
);
return new RateLimitError(id, retryAfterMs);
emitTorboxEvent({
endpoint,
sent: true,
status: res.status,
...request,
limitedBy: "torbox",
rule: id,
retryAfterMs,
usage,
detail: { retryAfter, headers, body }
});
return new RateLimitError(id, retryAfterMs, "torbox");
};
/** 当前受限的额度及多久后恢复,供前端在限流结束后自动重新加载 */
+34
View File
@@ -0,0 +1,34 @@
// 开发期统计:真实额度尚不明确,把每次 TorBox 请求与限流事件写进 SQLite 的 torbox_request_log,直接查库校准额度。
// 由 instrumentation 注册;额度确定后删掉注册的那几行和本文件,再从 schema 删表并生成迁移即可。
//
// 常用查询:
// -- 各类限流次数
// select limited_by, rule, count(*) from torbox_request_log
// where limited_by is not null group by 1, 2;
// -- 某次 TorBox 429 之前一小时实际发出的 requestdl 请求数(:at 换成那次 429 的 at)
// select count(*) from torbox_request_log
// where sent = 1 and endpoint like '%/requestdl' and at between :at - 3600000 and :at;
// -- 直链被下载节点拒绝时已用了多久(校准 TORBOX_DOWNLOAD_URL_TTL_MS)
// select json_extract(detail, '$.from') as url_from, json_extract(detail, '$.ageSec') as age_sec,
// json_extract(detail, '$.body') as body from torbox_request_log
// where cdn_host is not null and status = 400 order by at desc;
// -- 时间换成本地时间查看
// select datetime(at / 1000, 'unixepoch', 'localtime') as time, * from torbox_request_log
// order by at desc limit 50;
import { lt } from "drizzle-orm";
import { db } from "@/server/db";
import { torboxRequestLog } from "@/server/db/schema";
import { onTorboxEvent } from "@/server/torbox-events";
/** 只保留最近 30 天 */
const RETAIN_MS = 30 * 24 * 60 * 60 * 1000;
export const recordTorboxStats = () =>
onTorboxEvent((event) => {
db.transaction((tx) => {
tx.delete(torboxRequestLog)
.where(lt(torboxRequestLog.at, event.at - RETAIN_MS))
.run();
tx.insert(torboxRequestLog).values(event).run();
});
});
+75 -29
View File
@@ -5,6 +5,7 @@ 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 { emitTorboxEvent } from "@/server/torbox-events";
import {
getRateLimitHeaders,
limitTorbox,
@@ -68,10 +69,12 @@ const log = createLogger("torbox");
const torboxFetch = (path: string, init: RequestInit = {}) => {
// 只记录接口路径:查询串里有资源 id,requestdl 的查询串还带着 API Key
const endpoint = path.split("?")[0];
return limitTorbox(endpoint, async () => {
return limitTorbox(endpoint, async (queuedMs) => {
const startedAt = Date.now();
let res: Response;
try {
// body 为 FormData 时不能手动设置 Content-Type,fetch 会自动带上 boundary
const res = await fetch(TORBOX_API + path, {
res = await fetch(TORBOX_API + path, {
...init,
headers: {
Authorization: `Bearer ${apiKey()}`,
@@ -80,21 +83,45 @@ const torboxFetch = (path: string, init: RequestInit = {}) => {
signal: AbortSignal.timeout(15000),
cache: "no-store"
});
if (res.status === 429) throw await pauseTorbox(endpoint, res);
} catch (error) {
// 超时、网络错误:请求已发出,没有响应
emitTorboxEvent({
endpoint,
sent: true,
durationMs: Date.now() - startedAt,
queuedMs,
detail: { error: errorMessage(error) }
});
throw error;
}
const durationMs = Date.now() - startedAt;
if (res.status === 429)
throw await pauseTorbox(endpoint, res, { durationMs, queuedMs });
// 实际限制尚不明确:响应带了限流相关的头就记为 info,便于对照额度
const rateLimitHeaders = getRateLimitHeaders(res.headers);
log[rateLimitHeaders ? "info" : "debug"](
{
endpoint,
status: res.status,
durationMs: Date.now() - startedAt,
rateLimitHeaders
},
{ endpoint, status: res.status, durationMs, queuedMs, rateLimitHeaders },
"TorBox 请求"
);
const body = await res.json().catch(() => ({}));
if (!res.ok || body.success === false)
throw new Error(body.detail ?? res.statusText);
const failed = !res.ok || body.success === false;
emitTorboxEvent({
endpoint,
sent: true,
status: res.status,
durationMs,
queuedMs,
// TorBox 的业务错误码(如账号额度用尽)也可能是限流的信号
...(rateLimitHeaders || failed
? {
detail: {
rateLimitHeaders,
...(failed ? { error: body.error, message: body.detail } : {})
}
}
: {})
});
if (failed) throw new Error(body.detail ?? res.statusText);
return body.data;
});
};
@@ -292,9 +319,15 @@ const signDownloadUrl = async (
return url;
};
const getCachedUrl = (key: string, now: number) =>
db
.select({ url: torboxDownloadUrls.url })
/** signedAt:签发时间,直链被下载节点拒绝时据此看它用了多久,用来校准缓存有效期 */
type SignedUrl = { url: string; signedAt: number };
const getCachedUrl = (key: string, now: number): SignedUrl | undefined => {
const row = db
.select({
url: torboxDownloadUrls.url,
expiresAt: torboxDownloadUrls.expiresAt
})
.from(torboxDownloadUrls)
.where(
and(
@@ -302,9 +335,16 @@ const getCachedUrl = (key: string, now: number) =>
gt(torboxDownloadUrls.expiresAt, now)
)
)
.get()?.url;
.get();
return (
row && {
url: row.url,
signedAt: row.expiresAt - TORBOX_DOWNLOAD_URL_TTL_MS
}
);
};
const saveUrl = (key: string, url: string, now: number) => {
const saveUrl = (key: string, url: string, now: number): SignedUrl => {
const expiresAt = now + TORBOX_DOWNLOAD_URL_TTL_MS;
db.transaction((tx) => {
// 顺带清掉已过期的
@@ -319,13 +359,14 @@ const saveUrl = (key: string, url: string, now: number) => {
})
.run();
});
return { url, signedAt: now };
};
// 正在签发的直链(不是缓存,签发结束即移除):同一文件同时到来的请求
// (播放器连发的 Range、HEAD 后再 GET)共用一次签发。挂在 globalThis 上,避免各路由打包后各持一份
const SIGNING_KEY = Symbol.for("torbox-admin.signing-urls");
const signingGlobals = globalThis as {
[SIGNING_KEY]?: Map<string, Promise<string>>;
[SIGNING_KEY]?: Map<string, Promise<SignedUrl>>;
};
const signing = (signingGlobals[SIGNING_KEY] ??= new Map());
@@ -334,6 +375,14 @@ type RequestDownloadUrlOptions = {
fresh?: boolean;
};
/**
* 直链从哪来,写进日志:
* - cache:SQLite 里缓存的直链,没有请求 TorBox
* - shared:同一文件正在签发,等它的结果,本次没有另外请求 TorBox
* - signed:本次向 TorBox 签发
*/
export type DownloadUrlFrom = "cache" | "shared" | "signed";
/**
* 向 TorBox 换取临时直链(仅服务端使用,不下发给客户端)。
* requestdl 额度很紧,直链在 SQLite 里缓存 TORBOX_DOWNLOAD_URL_TTL_MS,重启后仍可复用
@@ -343,27 +392,24 @@ export const requestDownloadUrl = async (
id: string,
fileId: string,
{ fresh = false }: RequestDownloadUrlOptions = {}
): Promise<string> => {
): Promise<SignedUrl & { from: DownloadUrlFrom }> => {
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;
if (cached) return { ...cached, from: "cache" };
}
// 签发中的一定是新直链,fresh 也可以直接共用
let pending = signing.get(key);
if (!pending) {
pending = signDownloadUrl(type, id, fileId)
.then((url) => {
saveUrl(key, url, Date.now());
return url;
})
const pending = signing.get(key);
if (pending) return { ...(await pending), from: "shared" };
const signed = signDownloadUrl(type, id, fileId)
.then((url) => saveUrl(key, url, Date.now()))
.finally(() => signing.delete(key));
signing.set(key, pending);
}
return pending;
signing.set(key, signed);
return { ...(await signed), from: "signed" };
};
/**