diff --git a/docs/design-system.md b/docs/design-system.md index 9a6e3b8..b2c0d75 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -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` | diff --git a/drizzle/20261007035741_medical_quicksilver/migration.sql b/drizzle/20261007035741_medical_quicksilver/migration.sql new file mode 100644 index 0000000..f138647 --- /dev/null +++ b/drizzle/20261007035741_medical_quicksilver/migration.sql @@ -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`); \ No newline at end of file diff --git a/drizzle/20261007035741_medical_quicksilver/snapshot.json b/drizzle/20261007035741_medical_quicksilver/snapshot.json new file mode 100644 index 0000000..c14d9c5 --- /dev/null +++ b/drizzle/20261007035741_medical_quicksilver/snapshot.json @@ -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": [] +} \ No newline at end of file diff --git a/src/app/(app)/components/library-list.tsx b/src/app/(app)/components/library-list.tsx index 92f708b..caff753 100644 --- a/src/app/(app)/components/library-list.tsx +++ b/src/app/(app)/components/library-list.tsx @@ -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; }; -/** 记录首个可见行与偏移,供刷新页面后按锚点恢复 */ +/** 记录视口顶部(工具栏下沿)的行与偏移,供刷新页面后按锚点恢复 */ 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; 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( 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} diff --git a/src/app/(app)/components/library-toolbar.tsx b/src/app/(app)/components/library-toolbar.tsx index 9847016..0f9bdc3 100644 --- a/src/app/(app)/components/library-toolbar.tsx +++ b/src/app/(app)/components/library-toolbar.tsx @@ -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(""); diff --git a/src/app/(app)/components/library-view.tsx b/src/app/(app)/components/library-view.tsx index aa55aad..f4156f3 100644 --- a/src/app/(app)/components/library-view.tsx +++ b/src/app/(app)/components/library-view.tsx @@ -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(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(); + /** 工具栏完全展开时盖住的列表顶部高度;宽屏工具栏不浮在列表上,为 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 ( -
- - {/* scroll-pt-14:Tab 聚焦时浏览器自动滚动会避开资源列表的吸顶标题条 */} -
+ + + + {/* Tab 聚焦时浏览器自动滚动会避开工具栏与资源列表的吸顶标题条(3.5rem) */} + {content ? ( -
+
{content}
) : ( @@ -140,9 +196,11 @@ export const LibraryView = ({ token }: { token: string }) => { items={items} notices={notices} token={token} + toolbarInset={toolbarInset} + toolbarY={toolbarY} /> )} -
+
); }; diff --git a/src/app/api/hls/[type]/[id]/[fileId]/route.ts b/src/app/api/hls/[type]/[id]/[fileId]/route.ts index af1b54e..f3e4b99 100644 --- a/src/app/api/hls/[type]/[id]/[fileId]/route.ts +++ b/src/app/api/hls/[type]/[id]/[fileId]/route.ts @@ -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})`); } diff --git a/src/app/api/library/route.ts b/src/app/api/library/route.ts index f3c620b..ad55f45 100644 --- a/src/app/api/library/route.ts +++ b/src/app/api/library/route.ts @@ -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 } + : {}) }, "资源列表" ); diff --git a/src/app/api/stream/[type]/[id]/[fileId]/[[...name]]/route.ts b/src/app/api/stream/[type]/[id]/[fileId]/[[...name]]/route.ts index a8ee45c..f9c1773 100644 --- a/src/app/api/stream/[type]/[id]/[fileId]/[[...name]]/route.ts +++ b/src/app/api/stream/[type]/[id]/[fileId]/[[...name]]/route.ts @@ -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 = { + 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) { diff --git a/src/constants/motion.ts b/src/constants/motion.ts index 5d0b56e..c8c8655 100644 --- a/src/constants/motion.ts +++ b/src/constants/motion.ts @@ -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 } diff --git a/src/hooks/use-element-height.ts b/src/hooks/use-element-height.ts new file mode 100644 index 0000000..1677b5c --- /dev/null +++ b/src/hooks/use-element-height.ts @@ -0,0 +1,19 @@ +// 监听元素高度的 hook +import { useCallback, useState } from "react"; + +/** 元素边框盒高度(不受 transform 影响),挂载时同步测量,之后随 ResizeObserver 更新 */ +export const useElementHeight = () => { + 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; +}; diff --git a/src/hooks/use-hide-on-scroll.ts b/src/hooks/use-hide-on-scroll.ts new file mode 100644 index 0000000..ed464b6 --- /dev/null +++ b/src/hooks/use-hide-on-scroll.ts @@ -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, + { 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; +}; diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 9c13143..73bdb66 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -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(); } } diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 0291e2a..062ce18 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -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>(), + detail: text({ mode: "json" }).$type>() + }, + (table) => [index("torbox_request_log_at_idx").on(table.at)] +); diff --git a/src/server/torbox-events.ts b/src/server/torbox-events.ts new file mode 100644 index 0000000..a445dcc --- /dev/null +++ b/src/server/torbox-events.ts @@ -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; + /** 响应头、响应体、错误等其他信息 */ + detail?: Record; +}; + +type Listener = (event: TorboxEvent) => void; + +// 挂在 globalThis 上:订阅在 instrumentation 里完成,发出事件的是各路由,打包后不是同一个模块实例 +const LISTENERS_KEY = Symbol.for("torbox-admin.torbox-event-listeners"); +const globals = globalThis as { [LISTENERS_KEY]?: Set }; +const listeners: Set = (globals[LISTENERS_KEY] ??= new Set()); + +/** 订阅事件,返回取消订阅的函数 */ +export const onTorboxEvent = (listener: Listener) => { + listeners.add(listener); + return () => listeners.delete(listener); +}; + +/** 订阅方出错只记日志,不影响请求本身 */ +export const emitTorboxEvent = (event: Omit) => { + const full = { ...event, at: Date.now() }; + for (const listener of listeners) { + try { + listener(full); + } catch (error) { + log.error({ err: error }, "TorBox 事件处理失败"); + } + } +}; diff --git a/src/server/torbox-limiter.ts b/src/server/torbox-limiter.ts index 15c9742..27e74bc 100644 --- a/src/server/torbox-limiter.ts +++ b/src/server/torbox-limiter.ts @@ -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 = { + 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 ( endpoint: string, - task: () => Promise + task: (queuedMs: number) => Promise ) => { + 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"); }; /** 当前受限的额度及多久后恢复,供前端在限流结束后自动重新加载 */ diff --git a/src/server/torbox-stats.ts b/src/server/torbox-stats.ts new file mode 100644 index 0000000..cc505b4 --- /dev/null +++ b/src/server/torbox-stats.ts @@ -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(); + }); + }); diff --git a/src/server/torbox.ts b/src/server/torbox.ts index db9b8e9..f2cad05 100644 --- a/src/server/torbox.ts +++ b/src/server/torbox.ts @@ -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>; + [SIGNING_KEY]?: Map>; }; 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 => { +): Promise => { 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" }; }; /**