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