增加磁链测活功能
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
'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 CapsuleSwitch from '@/components/CapsuleSwitch';
|
||||
@@ -17,6 +23,19 @@ interface AcgSearchItem {
|
||||
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 {
|
||||
keyword: string;
|
||||
page: number;
|
||||
@@ -78,6 +97,12 @@ export default function AcgSearch({
|
||||
const [customName, setCustomName] = useState('');
|
||||
const [downloadTool, setDownloadTool] = useState<DownloadTool>('aria2');
|
||||
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 isLoadingMoreRef = useRef(false);
|
||||
const didInitSourceRef = useRef(false);
|
||||
@@ -155,6 +180,7 @@ export default function AcgSearch({
|
||||
} else {
|
||||
// 新搜索,重置数据
|
||||
setAllItems(data.items);
|
||||
setHealthMap({});
|
||||
// 如果第一页有结果,假设可能还有更多
|
||||
setHasMore(
|
||||
source !== 'mikan' &&
|
||||
@@ -247,6 +273,83 @@ export default function AcgSearch({
|
||||
};
|
||||
}, [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) => {
|
||||
setSelectedItem(item);
|
||||
@@ -375,8 +478,35 @@ export default function AcgSearch({
|
||||
</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
|
||||
onClick={() => handleOpenDownloadDialog(item)}
|
||||
disabled={downloadingId === item.guid}
|
||||
@@ -395,6 +525,24 @@ export default function AcgSearch({
|
||||
</>
|
||||
)}
|
||||
</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
|
||||
href={item.link}
|
||||
target='_blank'
|
||||
|
||||
+176
-23
@@ -2,6 +2,7 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
Copy,
|
||||
Download,
|
||||
@@ -25,6 +26,19 @@ interface PansouSearchProps {
|
||||
|
||||
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 }> = [
|
||||
{ value: 'aria2', label: 'aria2' },
|
||||
{ value: 'qBittorrent', label: 'qBittorrent' },
|
||||
@@ -179,10 +193,18 @@ export default function PansouSearch({
|
||||
const [checkStatesByType, setCheckStatesByType] = useState<
|
||||
Record<string, StoredCloudCheckState>
|
||||
>({});
|
||||
const [magnetHealthMap, setMagnetHealthMap] = useState<
|
||||
Record<string, MagnetHealthView>
|
||||
>({});
|
||||
const [magnetHealthCheckingIds, setMagnetHealthCheckingIds] = useState<
|
||||
Record<string, true>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
setCooldownRemainingMs(0);
|
||||
setCheckStatesByType({});
|
||||
setMagnetHealthMap({});
|
||||
setMagnetHealthCheckingIds({});
|
||||
}, [keyword, triggerSearch]);
|
||||
|
||||
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) => {
|
||||
setSelectedDownloadLink(link);
|
||||
setCustomName(keyword.trim() || link.note || '');
|
||||
@@ -879,28 +977,57 @@ export default function PansouSearch({
|
||||
</>
|
||||
)}
|
||||
{cloudType === 'magnet' && (
|
||||
<button
|
||||
onClick={() => handleOpenDownloadDialog(link)}
|
||||
disabled={downloadingUrl === link.url}
|
||||
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 ? (
|
||||
<>
|
||||
<Loader2 className='h-3.5 w-3.5 animate-spin' />
|
||||
<span className='hidden sm:inline'>
|
||||
下载中...
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className='h-3.5 w-3.5' />
|
||||
<span className='hidden sm:inline'>
|
||||
存到私人影库
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleOpenDownloadDialog(link)}
|
||||
disabled={downloadingUrl === link.url}
|
||||
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 ? (
|
||||
<>
|
||||
<Loader2 className='h-3.5 w-3.5 animate-spin' />
|
||||
<span className='hidden sm:inline'>
|
||||
下载中...
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className='h-3.5 w-3.5' />
|
||||
<span className='hidden sm:inline'>
|
||||
存到私人影库
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</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
|
||||
onClick={() =>
|
||||
@@ -933,13 +1060,39 @@ export default function PansouSearch({
|
||||
</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.datetime && (
|
||||
<span>
|
||||
{new Date(link.datetime).toLocaleDateString()}
|
||||
</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(
|
||||
cloudType,
|
||||
|
||||
Reference in New Issue
Block a user