feat: 增加限流记录
This commit is contained in:
@@ -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": []
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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">
|
||||
<LibraryToolbar
|
||||
sourceCounts={sourceCounts}
|
||||
summary={summary}
|
||||
anyExpanded={anyExpanded}
|
||||
isFetching={query.isFetching}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
{/* scroll-pt-14:Tab 聚焦时浏览器自动滚动会避开资源列表的吸顶标题条 */}
|
||||
<div
|
||||
// 手机宽度下工具栏与滚动容器叠放在同一格(单格 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}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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})`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
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)
|
||||
if (!upstream.ok) {
|
||||
const limited = upstream.status === 429;
|
||||
// 节点限流的规则尚不明确,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(
|
||||
{
|
||||
...media,
|
||||
method,
|
||||
cdnHost,
|
||||
upstreamStatus: upstream.status,
|
||||
contentType: upstream.headers.get("content-type"),
|
||||
// 节点限流的规则尚不明确,429 时记下全部响应头
|
||||
headers:
|
||||
upstream.status === 429
|
||||
? getResponseHeaders(upstream.headers)
|
||||
: getRateLimitHeaders(upstream.headers)
|
||||
},
|
||||
"上游返回异常"
|
||||
{ ...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) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
);
|
||||
|
||||
@@ -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 事件处理失败");
|
||||
}
|
||||
}
|
||||
};
|
||||
+112
-29
@@ -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(
|
||||
{
|
||||
endpoint,
|
||||
rule: blocking,
|
||||
retryAfterMs: waitMs,
|
||||
usage: describeUsage(ids, now)
|
||||
},
|
||||
"TorBox 额度用尽,拒绝请求"
|
||||
{ ...fields, retryAfterMs: waitMs },
|
||||
`${reason},拒绝请求(未发往 TorBox)`
|
||||
);
|
||||
throw new RateLimitError(blocking, waitMs);
|
||||
emitTorboxEvent({
|
||||
endpoint,
|
||||
sent: false,
|
||||
queuedMs: now - startedAt,
|
||||
limitedBy,
|
||||
rule: blocking,
|
||||
retryAfterMs: 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");
|
||||
};
|
||||
|
||||
/** 当前受限的额度及多久后恢复,供前端在限流结束后自动重新加载 */
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+85
-39
@@ -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,33 +69,59 @@ 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();
|
||||
// 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);
|
||||
let res: Response;
|
||||
try {
|
||||
// body 为 FormData 时不能手动设置 Content-Type,fetch 会自动带上 boundary
|
||||
res = await fetch(TORBOX_API + path, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey()}`,
|
||||
Accept: "application/json"
|
||||
},
|
||||
signal: AbortSignal.timeout(15000),
|
||||
cache: "no-store"
|
||||
});
|
||||
} 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;
|
||||
})
|
||||
.finally(() => signing.delete(key));
|
||||
signing.set(key, pending);
|
||||
}
|
||||
return pending;
|
||||
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, signed);
|
||||
return { ...(await signed), from: "signed" };
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user