增加磁链测活功能
This commit is contained in:
@@ -504,6 +504,7 @@ dockge/komodo 等 docker compose UI 也有自动更新功能
|
|||||||
| NEXT_PUBLIC_ENABLE_SOURCE_SEARCH | 是否开启源站寻片功能 | true/false | true |
|
| NEXT_PUBLIC_ENABLE_SOURCE_SEARCH | 是否开启源站寻片功能 | true/false | true |
|
||||||
| MAX_PLAY_RECORDS_PER_USER | 单个用户播放记录清理阈值(超过此数量将自动清理旧记录) | 正整数 | 100 |
|
| MAX_PLAY_RECORDS_PER_USER | 单个用户播放记录清理阈值(超过此数量将自动清理旧记录) | 正整数 | 100 |
|
||||||
| MAX_MANGA_HISTORY_PER_USER | 单个用户漫画阅读历史保留上限 | 正整数 | 100 |
|
| MAX_MANGA_HISTORY_PER_USER | 单个用户漫画阅读历史保留上限 | 正整数 | 100 |
|
||||||
|
| MAGNET_HEALTH_MAX_CONCURRENT | 动漫磁力测活全站同时进行的最大任务数(进程内) | 1-100 | 10 |
|
||||||
| INIT_CONFIG | 初始配置(JSON 格式,包含 api_site、custom_category、lives 等) | JSON 字符串 | (空) |
|
| INIT_CONFIG | 初始配置(JSON 格式,包含 api_site、custom_category、lives 等) | JSON 字符串 | (空) |
|
||||||
| CONFIG_SUBSCRIPTION_URL | 配置订阅 URL(Base58 编码的配置文件地址,优先级高于 INIT_CONFIG) | URL | (空) |
|
| CONFIG_SUBSCRIPTION_URL | 配置订阅 URL(Base58 编码的配置文件地址,优先级高于 INIT_CONFIG) | URL | (空) |
|
||||||
| TMDB_API_KEY | TMDB API 密钥 | 任意字符串 | (空) |
|
| TMDB_API_KEY | TMDB API 密钥 | 任意字符串 | (空) |
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import {
|
||||||
|
getMagnetHealthConcurrency,
|
||||||
|
MagnetHealthBusyError,
|
||||||
|
probeMagnetHealth,
|
||||||
|
} from '@/lib/magnet-health';
|
||||||
|
import { hasFeaturePermission } from '@/lib/permissions';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/acg/health
|
||||||
|
* 单条磁力/种子 Tracker scrape 测活(动漫磁链搜索 / 网盘搜索磁力类型共用)
|
||||||
|
* body: { url: string, skipCache?: boolean }
|
||||||
|
* 全站同时测活上限:环境变量 MAGNET_HEALTH_MAX_CONCURRENT(默认 10)
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authInfo = getAuthInfoFromCookie(req);
|
||||||
|
if (!authInfo?.username) {
|
||||||
|
return NextResponse.json({ error: '无权限访问' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const canUse =
|
||||||
|
(await hasFeaturePermission(authInfo.username, 'magnet_search')) ||
|
||||||
|
(await hasFeaturePermission(authInfo.username, 'netdisk_search'));
|
||||||
|
if (!canUse) {
|
||||||
|
return NextResponse.json({ error: '无权限访问' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const url = typeof body?.url === 'string' ? body.url.trim() : '';
|
||||||
|
const skipCache = Boolean(body?.skipCache);
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return NextResponse.json({ error: '链接不能为空' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 粗限长度,避免乱丢超大 body
|
||||||
|
if (url.length > 8192) {
|
||||||
|
return NextResponse.json({ error: '链接过长' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
const result = await probeMagnetHealth({
|
||||||
|
url,
|
||||||
|
proxy: config.SiteConfig.MagnetProxy || undefined,
|
||||||
|
skipCache,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
...result,
|
||||||
|
concurrency: getMagnetHealthConcurrency(),
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error instanceof MagnetHealthBusyError || error?.code === 'MAGNET_HEALTH_BUSY') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: error.message || '测活繁忙,请稍后再试',
|
||||||
|
code: 'MAGNET_HEALTH_BUSY',
|
||||||
|
concurrency: getMagnetHealthConcurrency(),
|
||||||
|
},
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('磁力测活失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error?.message || '测活失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { AlertCircle, Download, ExternalLink, Loader2 } from 'lucide-react';
|
import {
|
||||||
|
Activity,
|
||||||
|
AlertCircle,
|
||||||
|
Download,
|
||||||
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import CapsuleSwitch from '@/components/CapsuleSwitch';
|
import CapsuleSwitch from '@/components/CapsuleSwitch';
|
||||||
@@ -17,6 +23,19 @@ interface AcgSearchItem {
|
|||||||
images: string[];
|
images: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MagnetHealthLevel = 'good' | 'ok' | 'risk' | 'unknown';
|
||||||
|
|
||||||
|
interface MagnetHealthView {
|
||||||
|
health: MagnetHealthLevel;
|
||||||
|
seeders: number;
|
||||||
|
leechers: number;
|
||||||
|
peers: number;
|
||||||
|
message: string;
|
||||||
|
infoHash?: string;
|
||||||
|
source?: 'scrape' | 'cache';
|
||||||
|
durationMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface AcgSearchResult {
|
interface AcgSearchResult {
|
||||||
keyword: string;
|
keyword: string;
|
||||||
page: number;
|
page: number;
|
||||||
@@ -78,6 +97,12 @@ export default function AcgSearch({
|
|||||||
const [customName, setCustomName] = useState('');
|
const [customName, setCustomName] = useState('');
|
||||||
const [downloadTool, setDownloadTool] = useState<DownloadTool>('aria2');
|
const [downloadTool, setDownloadTool] = useState<DownloadTool>('aria2');
|
||||||
const [toast, setToast] = useState<ToastProps | null>(null);
|
const [toast, setToast] = useState<ToastProps | null>(null);
|
||||||
|
const [healthMap, setHealthMap] = useState<Record<string, MagnetHealthView>>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
const [healthCheckingIds, setHealthCheckingIds] = useState<
|
||||||
|
Record<string, true>
|
||||||
|
>({});
|
||||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||||
const isLoadingMoreRef = useRef(false);
|
const isLoadingMoreRef = useRef(false);
|
||||||
const didInitSourceRef = useRef(false);
|
const didInitSourceRef = useRef(false);
|
||||||
@@ -155,6 +180,7 @@ export default function AcgSearch({
|
|||||||
} else {
|
} else {
|
||||||
// 新搜索,重置数据
|
// 新搜索,重置数据
|
||||||
setAllItems(data.items);
|
setAllItems(data.items);
|
||||||
|
setHealthMap({});
|
||||||
// 如果第一页有结果,假设可能还有更多
|
// 如果第一页有结果,假设可能还有更多
|
||||||
setHasMore(
|
setHasMore(
|
||||||
source !== 'mikan' &&
|
source !== 'mikan' &&
|
||||||
@@ -247,6 +273,83 @@ export default function AcgSearch({
|
|||||||
};
|
};
|
||||||
}, [loadMore]);
|
}, [loadMore]);
|
||||||
|
|
||||||
|
const healthBadgeClass = (level: MagnetHealthLevel) => {
|
||||||
|
switch (level) {
|
||||||
|
case 'good':
|
||||||
|
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300';
|
||||||
|
case 'ok':
|
||||||
|
return 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200';
|
||||||
|
case 'risk':
|
||||||
|
return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const healthLabel = (level: MagnetHealthLevel) => {
|
||||||
|
switch (level) {
|
||||||
|
case 'good':
|
||||||
|
return '健康';
|
||||||
|
case 'ok':
|
||||||
|
return '一般';
|
||||||
|
case 'risk':
|
||||||
|
return '风险';
|
||||||
|
default:
|
||||||
|
return '未知';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 单条测活(全站并发由服务端限制为 10;前端可同时点多条)
|
||||||
|
const handleCheckHealth = async (item: AcgSearchItem) => {
|
||||||
|
if (!item.torrentUrl || healthCheckingIds[item.guid]) return;
|
||||||
|
|
||||||
|
setHealthCheckingIds((prev) => ({ ...prev, [item.guid]: true }));
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/acg/health', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
url: item.torrentUrl,
|
||||||
|
// 已有结果时点「重新测活」跳过缓存
|
||||||
|
skipCache: Boolean(healthMap[item.guid]),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || '测活失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
setHealthMap((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[item.guid]: {
|
||||||
|
health: data.health as MagnetHealthLevel,
|
||||||
|
seeders: data.seeders ?? 0,
|
||||||
|
leechers: data.leechers ?? 0,
|
||||||
|
peers: data.peers ?? 0,
|
||||||
|
message: data.message || '',
|
||||||
|
infoHash: data.infoHash,
|
||||||
|
source: data.source,
|
||||||
|
durationMs: data.durationMs,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (err: any) {
|
||||||
|
setToast({
|
||||||
|
message: err.message || '测活失败',
|
||||||
|
type: 'error',
|
||||||
|
onClose: () => setToast(null),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setHealthCheckingIds((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[item.guid];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 打开命名弹窗
|
// 打开命名弹窗
|
||||||
const handleOpenDownloadDialog = (item: AcgSearchItem) => {
|
const handleOpenDownloadDialog = (item: AcgSearchItem) => {
|
||||||
setSelectedItem(item);
|
setSelectedItem(item);
|
||||||
@@ -375,8 +478,35 @@ export default function AcgSearch({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 测活结果 */}
|
||||||
|
{healthMap[item.guid] && (
|
||||||
|
<div className='mb-3 flex flex-wrap items-center gap-2 text-xs'>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-2 py-0.5 font-medium ${healthBadgeClass(
|
||||||
|
healthMap[item.guid].health
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{healthLabel(healthMap[item.guid].health)}
|
||||||
|
</span>
|
||||||
|
<span className='text-gray-600 dark:text-gray-300'>
|
||||||
|
Seeder {healthMap[item.guid].seeders}
|
||||||
|
{' · '}
|
||||||
|
Leecher {healthMap[item.guid].leechers}
|
||||||
|
{' · '}
|
||||||
|
Peer {healthMap[item.guid].peers}
|
||||||
|
</span>
|
||||||
|
{typeof healthMap[item.guid].durationMs === 'number' && (
|
||||||
|
<span className='text-gray-400 dark:text-gray-500'>
|
||||||
|
{healthMap[item.guid].source === 'cache'
|
||||||
|
? '缓存'
|
||||||
|
: `${healthMap[item.guid].durationMs}ms`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex flex-wrap items-center gap-2'>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleOpenDownloadDialog(item)}
|
onClick={() => handleOpenDownloadDialog(item)}
|
||||||
disabled={downloadingId === item.guid}
|
disabled={downloadingId === item.guid}
|
||||||
@@ -395,6 +525,24 @@ export default function AcgSearch({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCheckHealth(item)}
|
||||||
|
disabled={!item.torrentUrl || Boolean(healthCheckingIds[item.guid])}
|
||||||
|
className='flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-sky-600 text-white text-sm hover:bg-sky-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors'
|
||||||
|
title='Tracker 测活(全站并发上限可由 MAGNET_HEALTH_MAX_CONCURRENT 配置)'
|
||||||
|
>
|
||||||
|
{healthCheckingIds[item.guid] ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className='h-4 w-4 animate-spin' />
|
||||||
|
<span>测活中...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Activity className='h-4 w-4' />
|
||||||
|
<span>{healthMap[item.guid] ? '重新测活' : '测活'}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
<a
|
<a
|
||||||
href={item.link}
|
href={item.link}
|
||||||
target='_blank'
|
target='_blank'
|
||||||
|
|||||||
+176
-23
@@ -2,6 +2,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
Activity,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
@@ -25,6 +26,19 @@ interface PansouSearchProps {
|
|||||||
|
|
||||||
type DownloadTool = 'aria2' | 'Transmission' | 'qBittorrent';
|
type DownloadTool = 'aria2' | 'Transmission' | 'qBittorrent';
|
||||||
|
|
||||||
|
type MagnetHealthLevel = 'good' | 'ok' | 'risk' | 'unknown';
|
||||||
|
|
||||||
|
interface MagnetHealthView {
|
||||||
|
health: MagnetHealthLevel;
|
||||||
|
seeders: number;
|
||||||
|
leechers: number;
|
||||||
|
peers: number;
|
||||||
|
message: string;
|
||||||
|
infoHash?: string;
|
||||||
|
source?: 'scrape' | 'cache';
|
||||||
|
durationMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
const downloadToolOptions: Array<{ value: DownloadTool; label: string }> = [
|
const downloadToolOptions: Array<{ value: DownloadTool; label: string }> = [
|
||||||
{ value: 'aria2', label: 'aria2' },
|
{ value: 'aria2', label: 'aria2' },
|
||||||
{ value: 'qBittorrent', label: 'qBittorrent' },
|
{ value: 'qBittorrent', label: 'qBittorrent' },
|
||||||
@@ -179,10 +193,18 @@ export default function PansouSearch({
|
|||||||
const [checkStatesByType, setCheckStatesByType] = useState<
|
const [checkStatesByType, setCheckStatesByType] = useState<
|
||||||
Record<string, StoredCloudCheckState>
|
Record<string, StoredCloudCheckState>
|
||||||
>({});
|
>({});
|
||||||
|
const [magnetHealthMap, setMagnetHealthMap] = useState<
|
||||||
|
Record<string, MagnetHealthView>
|
||||||
|
>({});
|
||||||
|
const [magnetHealthCheckingIds, setMagnetHealthCheckingIds] = useState<
|
||||||
|
Record<string, true>
|
||||||
|
>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCooldownRemainingMs(0);
|
setCooldownRemainingMs(0);
|
||||||
setCheckStatesByType({});
|
setCheckStatesByType({});
|
||||||
|
setMagnetHealthMap({});
|
||||||
|
setMagnetHealthCheckingIds({});
|
||||||
}, [keyword, triggerSearch]);
|
}, [keyword, triggerSearch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -357,6 +379,82 @@ export default function PansouSearch({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const magnetHealthBadgeClass = (level: MagnetHealthLevel) => {
|
||||||
|
switch (level) {
|
||||||
|
case 'good':
|
||||||
|
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300';
|
||||||
|
case 'ok':
|
||||||
|
return 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200';
|
||||||
|
case 'risk':
|
||||||
|
return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const magnetHealthLabel = (level: MagnetHealthLevel) => {
|
||||||
|
switch (level) {
|
||||||
|
case 'good':
|
||||||
|
return '健康';
|
||||||
|
case 'ok':
|
||||||
|
return '一般';
|
||||||
|
case 'risk':
|
||||||
|
return '风险';
|
||||||
|
default:
|
||||||
|
return '未知';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 磁力单条测活(与动漫磁链搜索共用 /api/acg/health,全站并发可配)
|
||||||
|
const handleMagnetHealthCheck = async (link: PansouLink) => {
|
||||||
|
const url = link.url?.trim();
|
||||||
|
if (!url || magnetHealthCheckingIds[url]) return;
|
||||||
|
|
||||||
|
setMagnetHealthCheckingIds((prev) => ({ ...prev, [url]: true }));
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/acg/health', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
url,
|
||||||
|
skipCache: Boolean(magnetHealthMap[url]),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || '测活失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
setMagnetHealthMap((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[url]: {
|
||||||
|
health: data.health as MagnetHealthLevel,
|
||||||
|
seeders: data.seeders ?? 0,
|
||||||
|
leechers: data.leechers ?? 0,
|
||||||
|
peers: data.peers ?? 0,
|
||||||
|
message: data.message || '',
|
||||||
|
infoHash: data.infoHash,
|
||||||
|
source: data.source,
|
||||||
|
durationMs: data.durationMs,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (err: any) {
|
||||||
|
setToast({
|
||||||
|
message: err?.message || '测活失败',
|
||||||
|
type: 'error',
|
||||||
|
onClose: () => setToast(null),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setMagnetHealthCheckingIds((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[url];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleOpenDownloadDialog = (link: PansouLink) => {
|
const handleOpenDownloadDialog = (link: PansouLink) => {
|
||||||
setSelectedDownloadLink(link);
|
setSelectedDownloadLink(link);
|
||||||
setCustomName(keyword.trim() || link.note || '');
|
setCustomName(keyword.trim() || link.note || '');
|
||||||
@@ -879,28 +977,57 @@ export default function PansouSearch({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{cloudType === 'magnet' && (
|
{cloudType === 'magnet' && (
|
||||||
<button
|
<>
|
||||||
onClick={() => handleOpenDownloadDialog(link)}
|
<button
|
||||||
disabled={downloadingUrl === link.url}
|
onClick={() => handleOpenDownloadDialog(link)}
|
||||||
className='flex items-center gap-1.5 px-2 py-1 rounded-md bg-green-600 hover:bg-green-700 text-white text-xs transition-colors disabled:opacity-60'
|
disabled={downloadingUrl === link.url}
|
||||||
title='存到私人影库'
|
className='flex items-center gap-1.5 px-2 py-1 rounded-md bg-green-600 hover:bg-green-700 text-white text-xs transition-colors disabled:opacity-60'
|
||||||
>
|
title='存到私人影库'
|
||||||
{downloadingUrl === link.url ? (
|
>
|
||||||
<>
|
{downloadingUrl === link.url ? (
|
||||||
<Loader2 className='h-3.5 w-3.5 animate-spin' />
|
<>
|
||||||
<span className='hidden sm:inline'>
|
<Loader2 className='h-3.5 w-3.5 animate-spin' />
|
||||||
下载中...
|
<span className='hidden sm:inline'>
|
||||||
</span>
|
下载中...
|
||||||
</>
|
</span>
|
||||||
) : (
|
</>
|
||||||
<>
|
) : (
|
||||||
<Download className='h-3.5 w-3.5' />
|
<>
|
||||||
<span className='hidden sm:inline'>
|
<Download className='h-3.5 w-3.5' />
|
||||||
存到私人影库
|
<span className='hidden sm:inline'>
|
||||||
</span>
|
存到私人影库
|
||||||
</>
|
</span>
|
||||||
)}
|
</>
|
||||||
</button>
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleMagnetHealthCheck(link)}
|
||||||
|
disabled={
|
||||||
|
!link.url ||
|
||||||
|
Boolean(magnetHealthCheckingIds[link.url])
|
||||||
|
}
|
||||||
|
className='flex items-center gap-1.5 px-2 py-1 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-xs transition-colors disabled:opacity-60'
|
||||||
|
title='Tracker 测活(全站并发上限可由 MAGNET_HEALTH_MAX_CONCURRENT 配置)'
|
||||||
|
>
|
||||||
|
{magnetHealthCheckingIds[link.url] ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className='h-3.5 w-3.5 animate-spin' />
|
||||||
|
<span className='hidden sm:inline'>
|
||||||
|
测活中...
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Activity className='h-3.5 w-3.5' />
|
||||||
|
<span className='hidden sm:inline'>
|
||||||
|
{magnetHealthMap[link.url]
|
||||||
|
? '重新测活'
|
||||||
|
: '测活'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -933,13 +1060,39 @@ export default function PansouSearch({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 来源和时间 */}
|
{/* 来源和时间 */}
|
||||||
<div className='flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400'>
|
<div className='flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400 flex-wrap'>
|
||||||
{link.source && <span>来源: {link.source}</span>}
|
{link.source && <span>来源: {link.source}</span>}
|
||||||
{link.datetime && (
|
{link.datetime && (
|
||||||
<span>
|
<span>
|
||||||
{new Date(link.datetime).toLocaleDateString()}
|
{new Date(link.datetime).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{cloudType === 'magnet' && magnetHealthMap[link.url] && (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-2 py-0.5 font-medium ${magnetHealthBadgeClass(
|
||||||
|
magnetHealthMap[link.url].health
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{magnetHealthLabel(magnetHealthMap[link.url].health)}
|
||||||
|
</span>
|
||||||
|
<span className='text-gray-600 dark:text-gray-300'>
|
||||||
|
Seeder {magnetHealthMap[link.url].seeders}
|
||||||
|
{' · '}
|
||||||
|
Leecher {magnetHealthMap[link.url].leechers}
|
||||||
|
{' · '}
|
||||||
|
Peer {magnetHealthMap[link.url].peers}
|
||||||
|
</span>
|
||||||
|
{typeof magnetHealthMap[link.url].durationMs ===
|
||||||
|
'number' && (
|
||||||
|
<span className='text-gray-400 dark:text-gray-500'>
|
||||||
|
{magnetHealthMap[link.url].source === 'cache'
|
||||||
|
? '缓存'
|
||||||
|
: `${magnetHealthMap[link.url].durationMs}ms`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{(() => {
|
{(() => {
|
||||||
const checkResult = getCheckResultForUrl(
|
const checkResult = getCheckResultForUrl(
|
||||||
cloudType,
|
cloudType,
|
||||||
|
|||||||
@@ -0,0 +1,927 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
/**
|
||||||
|
* 磁力/种子 Tracker scrape 测活
|
||||||
|
* - 单条探测;全站并发上限由 slot / MAGNET_HEALTH_MAX_CONCURRENT 控制
|
||||||
|
* - udp:// 先真 UDP scrape,失败再降级同 host 的 HTTP scrape
|
||||||
|
* - http(s):// 直接 HTTP scrape
|
||||||
|
* - 多 tracker 取 max(seeders)/max(leechers)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHash, randomBytes } from 'crypto';
|
||||||
|
import dgram from 'dgram';
|
||||||
|
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||||
|
import nodeFetch from 'node-fetch';
|
||||||
|
|
||||||
|
export type MagnetHealthLevel = 'good' | 'ok' | 'risk' | 'unknown';
|
||||||
|
|
||||||
|
export interface MagnetHealthTrackerResult {
|
||||||
|
tracker: string;
|
||||||
|
ok: boolean;
|
||||||
|
ms: number;
|
||||||
|
/** 实际成功或最后尝试的协议 */
|
||||||
|
proto?: 'udp' | 'http';
|
||||||
|
seeders?: number;
|
||||||
|
leechers?: number;
|
||||||
|
downloaded?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MagnetHealthResult {
|
||||||
|
infoHash: string;
|
||||||
|
health: MagnetHealthLevel;
|
||||||
|
seeders: number;
|
||||||
|
leechers: number;
|
||||||
|
peers: number;
|
||||||
|
downloaded: number;
|
||||||
|
checkedAt: number;
|
||||||
|
durationMs: number;
|
||||||
|
source: 'scrape' | 'cache';
|
||||||
|
trackersTried: number;
|
||||||
|
trackersOk: number;
|
||||||
|
trackers: MagnetHealthTrackerResult[];
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GLOBAL_KEY = Symbol.for('__MOONTV_MAGNET_HEALTH__');
|
||||||
|
|
||||||
|
interface HealthGlobalState {
|
||||||
|
active: number;
|
||||||
|
cache: Map<string, { expires: number; result: MagnetHealthResult }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全站同时测活上限:MAGNET_HEALTH_MAX_CONCURRENT,默认 10,范围 1–100 */
|
||||||
|
export function resolveMagnetHealthMaxConcurrent(): number {
|
||||||
|
const raw = process.env.MAGNET_HEALTH_MAX_CONCURRENT;
|
||||||
|
const n = raw === undefined || raw === '' ? 10 : Number(raw);
|
||||||
|
if (!Number.isFinite(n)) return 10;
|
||||||
|
return Math.min(100, Math.max(1, Math.floor(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getState(): HealthGlobalState {
|
||||||
|
const g = globalThis as any;
|
||||||
|
if (!g[GLOBAL_KEY]) {
|
||||||
|
g[GLOBAL_KEY] = {
|
||||||
|
active: 0,
|
||||||
|
cache: new Map(),
|
||||||
|
} satisfies HealthGlobalState;
|
||||||
|
}
|
||||||
|
return g[GLOBAL_KEY] as HealthGlobalState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全站同时测活占用情况 */
|
||||||
|
export function getMagnetHealthConcurrency() {
|
||||||
|
const s = getState();
|
||||||
|
return { active: s.active, max: resolveMagnetHealthMaxConcurrent() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MagnetHealthBusyError extends Error {
|
||||||
|
code = 'MAGNET_HEALTH_BUSY' as const;
|
||||||
|
active: number;
|
||||||
|
max: number;
|
||||||
|
constructor(active: number, max: number) {
|
||||||
|
super(`测活繁忙:当前 ${active}/${max},请稍后再试`);
|
||||||
|
this.name = 'MagnetHealthBusyError';
|
||||||
|
this.active = active;
|
||||||
|
this.max = max;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryAcquireSlot(): boolean {
|
||||||
|
const s = getState();
|
||||||
|
const max = resolveMagnetHealthMaxConcurrent();
|
||||||
|
if (s.active >= max) return false;
|
||||||
|
s.active += 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseSlot(): void {
|
||||||
|
const s = getState();
|
||||||
|
s.active = Math.max(0, s.active - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 30 * 60 * 1000;
|
||||||
|
const PER_TRACKER_TIMEOUT_MS = 2500;
|
||||||
|
const TOTAL_TIMEOUT_MS = 4500;
|
||||||
|
|
||||||
|
/** 公开 HTTP scrape 兜底(magnet 自带 tr 常失效) */
|
||||||
|
export const DEFAULT_HTTP_TRACKERS = [
|
||||||
|
'http://tracker.opentrackr.org:1337/announce',
|
||||||
|
'http://opentracker.acgnx.se/announce',
|
||||||
|
'http://tracker.bt4g.com:2095/announce',
|
||||||
|
'http://nyaa.tracker.wf:7777/announce',
|
||||||
|
'http://t.nyaatracker.com/announce',
|
||||||
|
'http://open.acgtracker.com:1096/announce',
|
||||||
|
'http://tracker.kamigami.org:2710/announce',
|
||||||
|
'http://anidex.moe:6969/announce',
|
||||||
|
'https://tr.bangumi.moe:9696/announce',
|
||||||
|
'http://share.camoe.cn:8080/announce',
|
||||||
|
'http://t.acg.rip:6699/announce',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- bencode ----------
|
||||||
|
|
||||||
|
function bdecode(data: Buffer, idx = 0): [any, number] {
|
||||||
|
const mark = data[idx];
|
||||||
|
if (mark === 105) {
|
||||||
|
// i...e
|
||||||
|
const end = data.indexOf(101, idx + 1); // 'e'
|
||||||
|
if (end < 0) throw new Error('invalid bencode int');
|
||||||
|
return [parseInt(data.subarray(idx + 1, end).toString('ascii'), 10), end + 1];
|
||||||
|
}
|
||||||
|
if (mark === 108) {
|
||||||
|
// l...e
|
||||||
|
let i = idx + 1;
|
||||||
|
const list: any[] = [];
|
||||||
|
while (data[i] !== 101) {
|
||||||
|
const [v, next] = bdecode(data, i);
|
||||||
|
list.push(v);
|
||||||
|
i = next;
|
||||||
|
}
|
||||||
|
return [list, i + 1];
|
||||||
|
}
|
||||||
|
if (mark === 100) {
|
||||||
|
// d...e
|
||||||
|
let i = idx + 1;
|
||||||
|
const dict: Record<string, any> = Object.create(null);
|
||||||
|
while (data[i] !== 101) {
|
||||||
|
const [k, kNext] = bdecode(data, i);
|
||||||
|
const [v, vNext] = bdecode(data, kNext);
|
||||||
|
// 二进制 key(如 scrape 的 infoHash)用 latin1 保真,避免 utf8 破坏字节
|
||||||
|
let key: string;
|
||||||
|
if (typeof k === 'string') key = k;
|
||||||
|
else if (Buffer.isBuffer(k)) key = k.toString('latin1');
|
||||||
|
else key = String(k);
|
||||||
|
dict[key] = v;
|
||||||
|
i = vNext;
|
||||||
|
}
|
||||||
|
return [dict, i + 1];
|
||||||
|
}
|
||||||
|
// string: <len>:<bytes>
|
||||||
|
const colon = data.indexOf(58, idx); // ':'
|
||||||
|
if (colon < 0) throw new Error('invalid bencode string');
|
||||||
|
const len = parseInt(data.subarray(idx, colon).toString('ascii'), 10);
|
||||||
|
const start = colon + 1;
|
||||||
|
const end = start + len;
|
||||||
|
const slice = data.subarray(start, end);
|
||||||
|
// 20 字节大概率是 infoHash,保持二进制语义(经 dict 时转 latin1)
|
||||||
|
if (slice.length === 20) {
|
||||||
|
return [slice, end];
|
||||||
|
}
|
||||||
|
// 可打印 utf8 用 string,否则 Buffer
|
||||||
|
const asStr = slice.toString('utf8');
|
||||||
|
if (
|
||||||
|
Buffer.byteLength(asStr, 'utf8') === slice.length &&
|
||||||
|
!/[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(asStr)
|
||||||
|
) {
|
||||||
|
return [asStr, end];
|
||||||
|
}
|
||||||
|
return [slice, end];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解码并保留 info 字典原始字节,用于 infohash */
|
||||||
|
function bdecodeTorrent(data: Buffer): { root: any; infoRaw: Buffer | null } {
|
||||||
|
if (data[0] !== 100) throw new Error('torrent 根节点不是 dict');
|
||||||
|
let i = 1;
|
||||||
|
const root: Record<string, any> = Object.create(null);
|
||||||
|
let infoRaw: Buffer | null = null;
|
||||||
|
while (data[i] !== 101) {
|
||||||
|
const [k, kNext] = bdecode(data, i);
|
||||||
|
const key = typeof k === 'string' ? k : Buffer.isBuffer(k) ? k.toString('latin1') : String(k);
|
||||||
|
if (key === 'info') {
|
||||||
|
const valueStart = kNext;
|
||||||
|
const [, valueEnd] = bdecode(data, valueStart);
|
||||||
|
infoRaw = data.subarray(valueStart, valueEnd);
|
||||||
|
const [v] = bdecode(data, valueStart);
|
||||||
|
root[key] = v;
|
||||||
|
i = valueEnd;
|
||||||
|
} else {
|
||||||
|
const [v, vNext] = bdecode(data, kNext);
|
||||||
|
root[key] = v;
|
||||||
|
i = vNext;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { root, infoRaw };
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufferishToString(v: any): string {
|
||||||
|
if (typeof v === 'string') return v;
|
||||||
|
if (Buffer.isBuffer(v)) return v.toString('utf8');
|
||||||
|
return String(v ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- magnet / torrent parse ----------
|
||||||
|
|
||||||
|
export function parseMagnetUri(magnet: string): { infoHash: string; trackers: string[] } {
|
||||||
|
const raw = magnet.trim();
|
||||||
|
if (!raw.toLowerCase().startsWith('magnet:')) {
|
||||||
|
throw new Error('不是有效的 magnet 链接');
|
||||||
|
}
|
||||||
|
const query = raw.indexOf('?') >= 0 ? raw.slice(raw.indexOf('?') + 1) : raw.slice('magnet:'.length);
|
||||||
|
const parts = query.split('&');
|
||||||
|
let infoHash = '';
|
||||||
|
const trackers: string[] = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!part) continue;
|
||||||
|
const eq = part.indexOf('=');
|
||||||
|
const k = eq >= 0 ? part.slice(0, eq) : part;
|
||||||
|
const v = eq >= 0 ? decodeURIComponent(part.slice(eq + 1)) : '';
|
||||||
|
const key = k.toLowerCase();
|
||||||
|
if (key === 'xt' || key.startsWith('xt.')) {
|
||||||
|
const m = v.match(/urn:btih:([a-zA-Z0-9]+)/i);
|
||||||
|
if (m) infoHash = normalizeInfoHash(m[1]);
|
||||||
|
} else if (key === 'tr') {
|
||||||
|
if (v) trackers.push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!infoHash) throw new Error('magnet 中缺少 infoHash');
|
||||||
|
return { infoHash, trackers };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInfoHash(input: string): string {
|
||||||
|
const s = input.trim();
|
||||||
|
if (/^[a-fA-F0-9]{40}$/.test(s)) return s.toLowerCase();
|
||||||
|
if (/^[a-zA-Z2-7]{32}$/.test(s)) {
|
||||||
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
|
let bits = '';
|
||||||
|
for (const c of s.toUpperCase()) {
|
||||||
|
const val = alphabet.indexOf(c);
|
||||||
|
if (val < 0) throw new Error('无效的 base32 infoHash');
|
||||||
|
bits += val.toString(2).padStart(5, '0');
|
||||||
|
}
|
||||||
|
const bytes: number[] = [];
|
||||||
|
for (let i = 0; i + 8 <= bits.length && bytes.length < 20; i += 8) {
|
||||||
|
bytes.push(parseInt(bits.slice(i, i + 8), 2));
|
||||||
|
}
|
||||||
|
if (bytes.length !== 20) throw new Error('无效的 base32 infoHash');
|
||||||
|
return Buffer.from(bytes).toString('hex');
|
||||||
|
}
|
||||||
|
throw new Error('无效的 infoHash');
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTrackersFromTorrentRoot(root: any): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const announce = root?.announce;
|
||||||
|
if (announce) out.push(bufferishToString(announce));
|
||||||
|
const list = root?.['announce-list'];
|
||||||
|
if (Array.isArray(list)) {
|
||||||
|
for (const tier of list) {
|
||||||
|
if (Array.isArray(tier)) {
|
||||||
|
for (const t of tier) out.push(bufferishToString(t));
|
||||||
|
} else if (tier) {
|
||||||
|
out.push(bufferishToString(tier));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBinary(
|
||||||
|
url: string,
|
||||||
|
proxy?: string,
|
||||||
|
timeoutMs = 15000
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const isCf = process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare';
|
||||||
|
if (isCf) {
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`下载失败 HTTP ${resp.status}`);
|
||||||
|
return Buffer.from(await resp.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
const init: any = {
|
||||||
|
headers: {
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
};
|
||||||
|
if (proxy) {
|
||||||
|
init.agent = new HttpsProxyAgent(proxy, { timeout: timeoutMs, keepAlive: false });
|
||||||
|
}
|
||||||
|
const resp = (await nodeFetch(url, init)) as any;
|
||||||
|
if (!resp.ok) throw new Error(`下载失败 HTTP ${resp.status}`);
|
||||||
|
const ab = await resp.arrayBuffer();
|
||||||
|
return Buffer.from(ab);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveTorrentIdentity(
|
||||||
|
url: string,
|
||||||
|
proxy?: string
|
||||||
|
): Promise<{ infoHash: string; trackers: string[] }> {
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (!trimmed) throw new Error('链接不能为空');
|
||||||
|
|
||||||
|
if (/^[a-fA-F0-9]{40}$/.test(trimmed) || /^[a-zA-Z2-7]{32}$/.test(trimmed)) {
|
||||||
|
return { infoHash: normalizeInfoHash(trimmed), trackers: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.toLowerCase().startsWith('magnet:')) {
|
||||||
|
return parseMagnetUri(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// .torrent or other http(s) url
|
||||||
|
if (!/^https?:\/\//i.test(trimmed)) {
|
||||||
|
throw new Error('仅支持 magnet、infoHash 或 http(s) 种子链接');
|
||||||
|
}
|
||||||
|
|
||||||
|
const bin = await fetchBinary(trimmed, proxy, 15000);
|
||||||
|
// some servers return HTML error pages
|
||||||
|
if (bin.length < 16 || bin[0] !== 100) {
|
||||||
|
const head = bin.subarray(0, 80).toString('utf8');
|
||||||
|
if (/magnet:\?/i.test(head)) {
|
||||||
|
const m = head.match(/magnet:\?[^\s"'<>]+/i);
|
||||||
|
if (m) return parseMagnetUri(m[0]);
|
||||||
|
}
|
||||||
|
throw new Error('下载内容不是有效的 .torrent 文件');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { root, infoRaw } = bdecodeTorrent(bin);
|
||||||
|
if (!infoRaw || infoRaw.length === 0) throw new Error('torrent 缺少 info 字段');
|
||||||
|
const infoHash = createHash('sha1').update(infoRaw).digest('hex');
|
||||||
|
const trackers = extractTrackersFromTorrentRoot(root);
|
||||||
|
return { infoHash, trackers };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- scrape ----------
|
||||||
|
|
||||||
|
const UDP_PROTOCOL_ID = BigInt('0x41727101980'); // magic for connect
|
||||||
|
|
||||||
|
function announceToHttpScrape(announceUrl: string): string | null {
|
||||||
|
try {
|
||||||
|
let u = announceUrl.trim();
|
||||||
|
if (!u) return null;
|
||||||
|
// 仅用于降级:把 udp 换成 http 再构 scrape
|
||||||
|
if (/^udp:\/\//i.test(u)) {
|
||||||
|
u = u.replace(/^udp:\/\//i, 'http://');
|
||||||
|
}
|
||||||
|
if (!/^https?:\/\//i.test(u)) return null;
|
||||||
|
if (/\/announce(\?.*)?$/i.test(u)) {
|
||||||
|
return u.replace(/\/announce(\?.*)?$/i, '/scrape$1');
|
||||||
|
}
|
||||||
|
if (u.includes('announce')) {
|
||||||
|
return u.replace(/announce/gi, 'scrape');
|
||||||
|
}
|
||||||
|
const parsed = new URL(u);
|
||||||
|
if (!parsed.pathname || parsed.pathname === '/') {
|
||||||
|
parsed.pathname = '/scrape';
|
||||||
|
} else {
|
||||||
|
parsed.pathname = parsed.pathname.replace(/\/?$/, '/scrape');
|
||||||
|
}
|
||||||
|
return parsed.toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUdpTracker(announceUrl: string): { host: string; port: number } | null {
|
||||||
|
try {
|
||||||
|
const u = new URL(announceUrl.replace(/^udp:\/\//i, 'http://'));
|
||||||
|
if (!u.hostname) return null;
|
||||||
|
const port = u.port ? parseInt(u.port, 10) : 80;
|
||||||
|
if (!Number.isFinite(port) || port <= 0) return null;
|
||||||
|
return { host: u.hostname, port };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProbablyDeadHost(hostname: string, ipHint?: string): boolean {
|
||||||
|
if (ipHint && /^(10\.|127\.|0\.|221\.229\.|100\.64\.)/.test(ipHint)) return true;
|
||||||
|
void hostname;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqTrackers(list: string[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const raw of list) {
|
||||||
|
const t = (raw || '').trim();
|
||||||
|
if (!t) continue;
|
||||||
|
// 保留协议差异:同一 host 的 udp 与 http 都保留,各自探测
|
||||||
|
const key = t.toLowerCase();
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(t);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BEP-15 UDP tracker scrape
|
||||||
|
* connect → scrape(info_hash) → complete / downloaded / incomplete
|
||||||
|
*/
|
||||||
|
async function scrapeOneUdp(
|
||||||
|
announceUrl: string,
|
||||||
|
infoHashHex: string,
|
||||||
|
timeoutMs = PER_TRACKER_TIMEOUT_MS
|
||||||
|
): Promise<MagnetHealthTrackerResult> {
|
||||||
|
const t0 = Date.now();
|
||||||
|
const parsed = parseUdpTracker(announceUrl);
|
||||||
|
if (!parsed) {
|
||||||
|
return {
|
||||||
|
tracker: announceUrl,
|
||||||
|
ok: false,
|
||||||
|
ms: 0,
|
||||||
|
proto: 'udp',
|
||||||
|
error: '无效的 UDP tracker 地址',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const infoHashRaw = Buffer.from(infoHashHex, 'hex');
|
||||||
|
if (infoHashRaw.length !== 20) {
|
||||||
|
return {
|
||||||
|
tracker: announceUrl,
|
||||||
|
ok: false,
|
||||||
|
ms: 0,
|
||||||
|
proto: 'udp',
|
||||||
|
error: 'infoHash 长度错误',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { host, port } = parsed;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const socket = dgram.createSocket('udp4');
|
||||||
|
let step: 'connect' | 'scrape' = 'connect';
|
||||||
|
const txnConnect = randomBytes(4).readUInt32BE(0);
|
||||||
|
let txnScrape = 0;
|
||||||
|
let connId = BigInt(0);
|
||||||
|
|
||||||
|
const finish = (result: MagnetHealthTrackerResult) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
finish({
|
||||||
|
tracker: announceUrl,
|
||||||
|
ok: false,
|
||||||
|
ms: Date.now() - t0,
|
||||||
|
proto: 'udp',
|
||||||
|
error: `UDP 超时(${step})`,
|
||||||
|
});
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
socket.on('error', (err) => {
|
||||||
|
finish({
|
||||||
|
tracker: announceUrl,
|
||||||
|
ok: false,
|
||||||
|
ms: Date.now() - t0,
|
||||||
|
proto: 'udp',
|
||||||
|
error: err?.message || 'UDP socket error',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('message', (msg) => {
|
||||||
|
try {
|
||||||
|
if (msg.length < 8) return;
|
||||||
|
const action = msg.readUInt32BE(0);
|
||||||
|
const txn = msg.readUInt32BE(4);
|
||||||
|
|
||||||
|
if (step === 'connect') {
|
||||||
|
if (action !== 0 || txn !== txnConnect || msg.length < 16) {
|
||||||
|
// action 3 = error
|
||||||
|
if (action === 3) {
|
||||||
|
const errMsg = msg.subarray(8).toString('utf8');
|
||||||
|
finish({
|
||||||
|
tracker: announceUrl,
|
||||||
|
ok: false,
|
||||||
|
ms: Date.now() - t0,
|
||||||
|
proto: 'udp',
|
||||||
|
error: `UDP connect 错误: ${errMsg || 'unknown'}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connId = msg.readBigUInt64BE(8);
|
||||||
|
step = 'scrape';
|
||||||
|
txnScrape = randomBytes(4).readUInt32BE(0);
|
||||||
|
const pkt = Buffer.alloc(16 + 20);
|
||||||
|
pkt.writeBigUInt64BE(connId, 0);
|
||||||
|
pkt.writeUInt32BE(2, 8); // scrape
|
||||||
|
pkt.writeUInt32BE(txnScrape, 12);
|
||||||
|
infoHashRaw.copy(pkt, 16);
|
||||||
|
socket.send(pkt, port, host);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === 'scrape') {
|
||||||
|
if (action === 3) {
|
||||||
|
const errMsg = msg.subarray(8).toString('utf8');
|
||||||
|
finish({
|
||||||
|
tracker: announceUrl,
|
||||||
|
ok: false,
|
||||||
|
ms: Date.now() - t0,
|
||||||
|
proto: 'udp',
|
||||||
|
error: `UDP scrape 错误: ${errMsg || 'unknown'}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action !== 2 || txn !== txnScrape || msg.length < 20) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const seeders = msg.readUInt32BE(8);
|
||||||
|
const downloaded = msg.readUInt32BE(12);
|
||||||
|
const leechers = msg.readUInt32BE(16);
|
||||||
|
finish({
|
||||||
|
tracker: announceUrl,
|
||||||
|
ok: true,
|
||||||
|
ms: Date.now() - t0,
|
||||||
|
proto: 'udp',
|
||||||
|
seeders,
|
||||||
|
leechers,
|
||||||
|
downloaded,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
finish({
|
||||||
|
tracker: announceUrl,
|
||||||
|
ok: false,
|
||||||
|
ms: Date.now() - t0,
|
||||||
|
proto: 'udp',
|
||||||
|
error: e?.message || 'UDP 解析失败',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// connect request: protocol_id(8) + action(4)=0 + transaction_id(4)
|
||||||
|
const pkt = Buffer.alloc(16);
|
||||||
|
pkt.writeBigUInt64BE(UDP_PROTOCOL_ID, 0);
|
||||||
|
pkt.writeUInt32BE(0, 8);
|
||||||
|
pkt.writeUInt32BE(txnConnect, 12);
|
||||||
|
socket.send(pkt, port, host, (err) => {
|
||||||
|
if (err) {
|
||||||
|
finish({
|
||||||
|
tracker: announceUrl,
|
||||||
|
ok: false,
|
||||||
|
ms: Date.now() - t0,
|
||||||
|
proto: 'udp',
|
||||||
|
error: err.message || 'UDP 发送失败',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
finish({
|
||||||
|
tracker: announceUrl,
|
||||||
|
ok: false,
|
||||||
|
ms: Date.now() - t0,
|
||||||
|
proto: 'udp',
|
||||||
|
error: e?.message || 'UDP 初始化失败',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrapeOneHttp(
|
||||||
|
announceUrl: string,
|
||||||
|
infoHashHex: string,
|
||||||
|
proxy?: string,
|
||||||
|
timeoutMs = PER_TRACKER_TIMEOUT_MS
|
||||||
|
): Promise<MagnetHealthTrackerResult> {
|
||||||
|
const t0 = Date.now();
|
||||||
|
const scrapeBase = announceToHttpScrape(announceUrl);
|
||||||
|
if (!scrapeBase) {
|
||||||
|
return {
|
||||||
|
tracker: announceUrl,
|
||||||
|
ok: false,
|
||||||
|
ms: 0,
|
||||||
|
proto: 'http',
|
||||||
|
error: '无法构造 scrape URL',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const host = new URL(scrapeBase).hostname;
|
||||||
|
if (isProbablyDeadHost(host)) {
|
||||||
|
return {
|
||||||
|
tracker: announceUrl,
|
||||||
|
ok: false,
|
||||||
|
ms: 0,
|
||||||
|
proto: 'http',
|
||||||
|
error: '跳过不可达 host',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
const infoHashRaw = Buffer.from(infoHashHex, 'hex');
|
||||||
|
if (infoHashRaw.length !== 20) {
|
||||||
|
return {
|
||||||
|
tracker: announceUrl,
|
||||||
|
ok: false,
|
||||||
|
ms: 0,
|
||||||
|
proto: 'http',
|
||||||
|
error: 'infoHash 长度错误',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const q = Array.from(infoHashRaw)
|
||||||
|
.map((b) => `%${b.toString(16).padStart(2, '0')}`)
|
||||||
|
.join('');
|
||||||
|
const url = scrapeBase.includes('?')
|
||||||
|
? `${scrapeBase}&info_hash=${q}`
|
||||||
|
: `${scrapeBase}?info_hash=${q}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bin = await fetchBinary(url, proxy, timeoutMs);
|
||||||
|
const ms = Date.now() - t0;
|
||||||
|
let decoded: any;
|
||||||
|
try {
|
||||||
|
[decoded] = bdecode(bin);
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
tracker: announceUrl,
|
||||||
|
ok: false,
|
||||||
|
ms,
|
||||||
|
proto: 'http',
|
||||||
|
error: 'scrape 响应不是 bencode',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!decoded || typeof decoded !== 'object' || !decoded.files) {
|
||||||
|
return {
|
||||||
|
tracker: announceUrl,
|
||||||
|
ok: false,
|
||||||
|
ms,
|
||||||
|
proto: 'http',
|
||||||
|
error: '不支持 scrape 或无 files 字段',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = decoded.files as Record<string, any>;
|
||||||
|
let stats: any = null;
|
||||||
|
const hashLatin1 = infoHashRaw.toString('latin1');
|
||||||
|
const hashHex = infoHashHex.toLowerCase();
|
||||||
|
|
||||||
|
for (const [k, v] of Object.entries(files)) {
|
||||||
|
if (k === hashLatin1 || k.toLowerCase() === hashHex) {
|
||||||
|
stats = v;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const asLatin1 = Buffer.from(k, 'latin1');
|
||||||
|
if (asLatin1.length === 20 && asLatin1.equals(infoHashRaw)) {
|
||||||
|
stats = v;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats && Object.keys(files).length === 1) {
|
||||||
|
stats = Object.values(files)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats || typeof stats !== 'object') {
|
||||||
|
return {
|
||||||
|
tracker: announceUrl,
|
||||||
|
ok: false,
|
||||||
|
ms,
|
||||||
|
proto: 'http',
|
||||||
|
error: 'scrape 未包含该 infoHash',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const seeders = Number(stats.complete ?? stats.seeders ?? 0) || 0;
|
||||||
|
const leechers = Number(stats.incomplete ?? stats.leechers ?? 0) || 0;
|
||||||
|
const downloaded = Number(stats.downloaded ?? 0) || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
tracker: announceUrl,
|
||||||
|
ok: true,
|
||||||
|
ms,
|
||||||
|
proto: 'http',
|
||||||
|
seeders,
|
||||||
|
leechers,
|
||||||
|
downloaded,
|
||||||
|
};
|
||||||
|
} catch (e: any) {
|
||||||
|
return {
|
||||||
|
tracker: announceUrl,
|
||||||
|
ok: false,
|
||||||
|
ms: Date.now() - t0,
|
||||||
|
proto: 'http',
|
||||||
|
error: e?.message || String(e),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单 tracker 测活:
|
||||||
|
* - udp:// → 先 UDP,失败再降级 HTTP
|
||||||
|
* - http(s):// → 仅 HTTP
|
||||||
|
*/
|
||||||
|
async function scrapeOneTracker(
|
||||||
|
announceUrl: string,
|
||||||
|
infoHashHex: string,
|
||||||
|
proxy?: string,
|
||||||
|
timeoutMs = PER_TRACKER_TIMEOUT_MS
|
||||||
|
): Promise<MagnetHealthTrackerResult> {
|
||||||
|
const isUdp = /^udp:\/\//i.test(announceUrl.trim());
|
||||||
|
|
||||||
|
if (isUdp) {
|
||||||
|
// UDP 分一半预算,失败后再用剩余时间打 HTTP,避免总超时翻倍
|
||||||
|
const udpBudget = Math.max(800, Math.floor(timeoutMs * 0.55));
|
||||||
|
const udpResult = await scrapeOneUdp(announceUrl, infoHashHex, udpBudget);
|
||||||
|
if (udpResult.ok) return udpResult;
|
||||||
|
|
||||||
|
const used = udpResult.ms || 0;
|
||||||
|
const httpBudget = Math.max(800, timeoutMs - used);
|
||||||
|
const httpResult = await scrapeOneHttp(announceUrl, infoHashHex, proxy, httpBudget);
|
||||||
|
if (httpResult.ok) {
|
||||||
|
return {
|
||||||
|
...httpResult,
|
||||||
|
ms: used + httpResult.ms,
|
||||||
|
// 标注:UDP 失败后 HTTP 降级成功
|
||||||
|
error: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tracker: announceUrl,
|
||||||
|
ok: false,
|
||||||
|
ms: used + (httpResult.ms || 0),
|
||||||
|
proto: 'http',
|
||||||
|
error: `UDP失败(${udpResult.error || 'unknown'});HTTP降级失败(${httpResult.error || 'unknown'})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return scrapeOneHttp(announceUrl, infoHashHex, proxy, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyHealth(seeders: number, peers: number, anyOk: boolean): MagnetHealthLevel {
|
||||||
|
if (!anyOk) return 'unknown';
|
||||||
|
if (seeders >= 5 && peers > 50) return 'good';
|
||||||
|
if (seeders >= 1 || peers >= 5) return 'ok';
|
||||||
|
if (seeders === 0 && peers === 0) return 'risk';
|
||||||
|
// 有 leecher 但无 seeder
|
||||||
|
if (seeders === 0) return 'risk';
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthMessage(level: MagnetHealthLevel, seeders: number, leechers: number, peers: number): string {
|
||||||
|
switch (level) {
|
||||||
|
case 'good':
|
||||||
|
return `健康:Seeder ${seeders} / Peer ${peers}`;
|
||||||
|
case 'ok':
|
||||||
|
return `一般:Seeder ${seeders} / Leecher ${leechers}`;
|
||||||
|
case 'risk':
|
||||||
|
return seeders === 0
|
||||||
|
? `风险:Seeder 0(Peer ${peers})`
|
||||||
|
: `风险:Seeder ${seeders} / Peer ${peers}`;
|
||||||
|
default:
|
||||||
|
return '未知:Tracker 均未返回有效数据';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCached(infoHash: string): MagnetHealthResult | null {
|
||||||
|
const s = getState();
|
||||||
|
const hit = s.cache.get(infoHash);
|
||||||
|
if (!hit) return null;
|
||||||
|
if (Date.now() > hit.expires) {
|
||||||
|
s.cache.delete(infoHash);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { ...hit.result, source: 'cache', checkedAt: hit.result.checkedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCache(result: MagnetHealthResult): void {
|
||||||
|
const s = getState();
|
||||||
|
s.cache.set(result.infoHash, {
|
||||||
|
expires: Date.now() + CACHE_TTL_MS,
|
||||||
|
result: { ...result, source: 'scrape' },
|
||||||
|
});
|
||||||
|
// 简单限制缓存规模
|
||||||
|
if (s.cache.size > 500) {
|
||||||
|
const first = s.cache.keys().next().value;
|
||||||
|
if (first) s.cache.delete(first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对单条 magnet / torrent URL / infoHash 做 tracker scrape 测活
|
||||||
|
*/
|
||||||
|
export async function probeMagnetHealth(options: {
|
||||||
|
url: string;
|
||||||
|
extraTrackers?: string[];
|
||||||
|
proxy?: string;
|
||||||
|
skipCache?: boolean;
|
||||||
|
}): Promise<MagnetHealthResult> {
|
||||||
|
const tAll = Date.now();
|
||||||
|
const { infoHash, trackers: parsedTrackers } = await resolveTorrentIdentity(
|
||||||
|
options.url,
|
||||||
|
options.proxy
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!options.skipCache) {
|
||||||
|
const cached = getCached(infoHash);
|
||||||
|
if (cached) {
|
||||||
|
return { ...cached, durationMs: Date.now() - tAll };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tryAcquireSlot()) {
|
||||||
|
const { active, max } = getMagnetHealthConcurrency();
|
||||||
|
throw new MagnetHealthBusyError(active, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 二次检查缓存(占槽前后可能已有结果)
|
||||||
|
if (!options.skipCache) {
|
||||||
|
const cached = getCached(infoHash);
|
||||||
|
if (cached) {
|
||||||
|
return { ...cached, durationMs: Date.now() - tAll };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackers = uniqTrackers([
|
||||||
|
...parsedTrackers,
|
||||||
|
...(options.extraTrackers || []),
|
||||||
|
...DEFAULT_HTTP_TRACKERS,
|
||||||
|
]).slice(0, 16);
|
||||||
|
|
||||||
|
const results: MagnetHealthTrackerResult[] = [];
|
||||||
|
let timedOut = false;
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const deadline = Date.now() + TOTAL_TIMEOUT_MS;
|
||||||
|
let pending = trackers.length;
|
||||||
|
if (pending === 0) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
resolve();
|
||||||
|
}, TOTAL_TIMEOUT_MS);
|
||||||
|
|
||||||
|
for (const tr of trackers) {
|
||||||
|
const remain = Math.max(500, deadline - Date.now());
|
||||||
|
scrapeOneTracker(tr, infoHash, options.proxy, Math.min(PER_TRACKER_TIMEOUT_MS, remain))
|
||||||
|
.then((r) => {
|
||||||
|
if (!timedOut) results.push(r);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!timedOut) {
|
||||||
|
results.push({
|
||||||
|
tracker: tr,
|
||||||
|
ok: false,
|
||||||
|
ms: 0,
|
||||||
|
error: e?.message || String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
pending -= 1;
|
||||||
|
if (pending <= 0) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const okList = results.filter((r) => r.ok);
|
||||||
|
const seeders = okList.reduce((m, r) => Math.max(m, r.seeders ?? 0), 0);
|
||||||
|
const leechers = okList.reduce((m, r) => Math.max(m, r.leechers ?? 0), 0);
|
||||||
|
const downloaded = okList.reduce((m, r) => Math.max(m, r.downloaded ?? 0), 0);
|
||||||
|
const peers = seeders + leechers;
|
||||||
|
const health = classifyHealth(seeders, peers, okList.length > 0);
|
||||||
|
|
||||||
|
const result: MagnetHealthResult = {
|
||||||
|
infoHash,
|
||||||
|
health,
|
||||||
|
seeders,
|
||||||
|
leechers,
|
||||||
|
peers,
|
||||||
|
downloaded,
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
durationMs: Date.now() - tAll,
|
||||||
|
source: 'scrape',
|
||||||
|
trackersTried: trackers.length,
|
||||||
|
trackersOk: okList.length,
|
||||||
|
trackers: results.sort((a, b) => Number(b.ok) - Number(a.ok) || (b.seeders ?? 0) - (a.seeders ?? 0)),
|
||||||
|
message: healthMessage(health, seeders, leechers, peers),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (okList.length > 0) {
|
||||||
|
setCache(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
releaseSlot();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user