动漫磁力搜索增加nyaa;动漫磁力搜索前可选站点
This commit is contained in:
@@ -385,6 +385,7 @@ interface SiteConfig {
|
|||||||
MagnetMikanReverseProxy?: string;
|
MagnetMikanReverseProxy?: string;
|
||||||
MagnetDmhyReverseProxy?: string;
|
MagnetDmhyReverseProxy?: string;
|
||||||
MagnetAcgripReverseProxy?: string;
|
MagnetAcgripReverseProxy?: string;
|
||||||
|
MagnetNyaaReverseProxy?: string;
|
||||||
EnableComments: boolean;
|
EnableComments: boolean;
|
||||||
EnableRegistration?: boolean;
|
EnableRegistration?: boolean;
|
||||||
RequireRegistrationInviteCode?: boolean;
|
RequireRegistrationInviteCode?: boolean;
|
||||||
@@ -10151,6 +10152,7 @@ const SiteConfigComponent = ({
|
|||||||
MagnetMikanReverseProxy: '',
|
MagnetMikanReverseProxy: '',
|
||||||
MagnetDmhyReverseProxy: '',
|
MagnetDmhyReverseProxy: '',
|
||||||
MagnetAcgripReverseProxy: '',
|
MagnetAcgripReverseProxy: '',
|
||||||
|
MagnetNyaaReverseProxy: '',
|
||||||
EnableComments: false,
|
EnableComments: false,
|
||||||
EnableRegistration: false,
|
EnableRegistration: false,
|
||||||
RegistrationRequireTurnstile: false,
|
RegistrationRequireTurnstile: false,
|
||||||
@@ -10273,6 +10275,7 @@ const SiteConfigComponent = ({
|
|||||||
MagnetDmhyReverseProxy: config.SiteConfig.MagnetDmhyReverseProxy || '',
|
MagnetDmhyReverseProxy: config.SiteConfig.MagnetDmhyReverseProxy || '',
|
||||||
MagnetAcgripReverseProxy:
|
MagnetAcgripReverseProxy:
|
||||||
config.SiteConfig.MagnetAcgripReverseProxy || '',
|
config.SiteConfig.MagnetAcgripReverseProxy || '',
|
||||||
|
MagnetNyaaReverseProxy: config.SiteConfig.MagnetNyaaReverseProxy || '',
|
||||||
EnableComments: config.SiteConfig.EnableComments || false,
|
EnableComments: config.SiteConfig.EnableComments || false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -11278,6 +11281,27 @@ const SiteConfigComponent = ({
|
|||||||
配置后将使用该地址替代默认的 ACG.RIP 域名进行请求。
|
配置后将使用该地址替代默认的 ACG.RIP 域名进行请求。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
Nyaa 反代代理
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='请输入 Nyaa 反代 Base URL(可选)'
|
||||||
|
value={siteSettings.MagnetNyaaReverseProxy || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSiteSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
MagnetNyaaReverseProxy: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||||
|
/>
|
||||||
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
配置后将使用该地址替代默认的 Nyaa 域名进行请求。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { parseStringPromise } from 'xml2js';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { getMagnetBaseUrl, universalMagnetFetch } from '@/lib/magnet.client';
|
||||||
|
import { hasFeaturePermission } from '@/lib/permissions';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
const pickText = (value: any): string => {
|
||||||
|
if (value === undefined || value === null) return '';
|
||||||
|
const first = Array.isArray(value) ? value[0] : value;
|
||||||
|
if (first === undefined || first === null) return '';
|
||||||
|
if (typeof first === 'object') return String(first._ ?? first.$?.url ?? first.$?.href ?? '');
|
||||||
|
return String(first);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/acg/nyaa
|
||||||
|
* 搜索 Nyaa RSS(仅管理员和站长可用,不支持分页)
|
||||||
|
* - https://nyaa.si/?page=rss&q=xxx&c=1_0&f=0
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authInfo = getAuthInfoFromCookie(req);
|
||||||
|
if (!authInfo?.username || !(await hasFeaturePermission(authInfo.username, 'magnet_search'))) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '无权限访问' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { keyword, page = 1 } = await req.json();
|
||||||
|
|
||||||
|
if (!keyword || typeof keyword !== 'string') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '搜索关键词不能为空' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedKeyword = keyword.trim();
|
||||||
|
if (!trimmedKeyword) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '搜索关键词不能为空' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageNum = parseInt(String(page), 10);
|
||||||
|
if (isNaN(pageNum) || pageNum < 1) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '页码必须是大于0的整数' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageNum > 1) {
|
||||||
|
return NextResponse.json({
|
||||||
|
keyword: trimmedKeyword,
|
||||||
|
page: pageNum,
|
||||||
|
total: 0,
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
const searchBaseUrl = getMagnetBaseUrl(
|
||||||
|
'https://nyaa.si',
|
||||||
|
config.SiteConfig.MagnetNyaaReverseProxy
|
||||||
|
);
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: 'rss',
|
||||||
|
q: trimmedKeyword,
|
||||||
|
c: '1_0',
|
||||||
|
f: '0',
|
||||||
|
});
|
||||||
|
const searchUrl = `${searchBaseUrl}/?${params.toString()}`;
|
||||||
|
|
||||||
|
const response = await universalMagnetFetch(searchUrl, config.SiteConfig.MagnetProxy, {
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Nyaa API 请求失败: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const xmlData = await response.text();
|
||||||
|
const parsed = await parseStringPromise(xmlData);
|
||||||
|
|
||||||
|
if (!parsed?.rss?.channel?.[0]?.item) {
|
||||||
|
return NextResponse.json({
|
||||||
|
keyword: trimmedKeyword,
|
||||||
|
page: pageNum,
|
||||||
|
total: 0,
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = parsed.rss.channel[0].item;
|
||||||
|
|
||||||
|
const results = items.map((item: any) => {
|
||||||
|
const title = pickText(item.title);
|
||||||
|
// Nyaa RSS 的 link 是 .torrent 下载地址,guid 才是详情页(且 guid 带 isPermaLink 属性)
|
||||||
|
const torrentUrl = pickText(item.link);
|
||||||
|
const detailUrl = pickText(item.guid) || torrentUrl;
|
||||||
|
const guid = detailUrl || torrentUrl || `${title}-${pickText(item.pubDate)}`;
|
||||||
|
const pubDate = pickText(item.pubDate);
|
||||||
|
const size = pickText(item['nyaa:size']);
|
||||||
|
const category = pickText(item['nyaa:category']);
|
||||||
|
const seeders = pickText(item['nyaa:seeders']);
|
||||||
|
const leechers = pickText(item['nyaa:leechers']);
|
||||||
|
const downloads = pickText(item['nyaa:downloads']);
|
||||||
|
const infoHash = pickText(item['nyaa:infoHash']);
|
||||||
|
const description =
|
||||||
|
pickText(item.description) ||
|
||||||
|
[
|
||||||
|
size && `大小:${size}`,
|
||||||
|
category && `分类:${category}`,
|
||||||
|
seeders && `Seeders:${seeders}`,
|
||||||
|
leechers && `Leechers:${leechers}`,
|
||||||
|
downloads && `下载:${downloads}`,
|
||||||
|
infoHash && `Hash:${infoHash}`,
|
||||||
|
].filter(Boolean).join(' | ');
|
||||||
|
|
||||||
|
let images: string[] = [];
|
||||||
|
if (description) {
|
||||||
|
const imgMatches = description.match(/src="([^"]+)"/g);
|
||||||
|
if (imgMatches) {
|
||||||
|
images = imgMatches.map((match: string) => {
|
||||||
|
const urlMatch = match.match(/src="([^"]+)"/);
|
||||||
|
return urlMatch ? urlMatch[1] : '';
|
||||||
|
}).filter(Boolean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
link: detailUrl,
|
||||||
|
guid,
|
||||||
|
pubDate,
|
||||||
|
torrentUrl,
|
||||||
|
description,
|
||||||
|
images,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
keyword: trimmedKeyword,
|
||||||
|
page: pageNum,
|
||||||
|
total: results.length,
|
||||||
|
items: results,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Nyaa 搜索失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || '搜索失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ export async function PUT(
|
|||||||
subscription.filterText = updates.filterText.trim();
|
subscription.filterText = updates.filterText.trim();
|
||||||
}
|
}
|
||||||
if (updates.source !== undefined) {
|
if (updates.source !== undefined) {
|
||||||
if (!['acgrip', 'mikan', 'dmhy'].includes(updates.source)) {
|
if (!['acgrip', 'mikan', 'dmhy', 'nyaa'].includes(updates.source)) {
|
||||||
return NextResponse.json({ error: '无效的搜索源' }, { status: 400 });
|
return NextResponse.json({ error: '无效的搜索源' }, { status: 400 });
|
||||||
}
|
}
|
||||||
subscription.source = updates.source;
|
subscription.source = updates.source;
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 验证 source
|
// 验证 source
|
||||||
if (!['acgrip', 'mikan', 'dmhy'].includes(source)) {
|
if (!['acgrip', 'mikan', 'dmhy', 'nyaa'].includes(source)) {
|
||||||
return NextResponse.json({ error: '无效的搜索源' }, { status: 400 });
|
return NextResponse.json({ error: '无效的搜索源' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export async function POST(request: NextRequest) {
|
|||||||
MagnetMikanReverseProxy,
|
MagnetMikanReverseProxy,
|
||||||
MagnetDmhyReverseProxy,
|
MagnetDmhyReverseProxy,
|
||||||
MagnetAcgripReverseProxy,
|
MagnetAcgripReverseProxy,
|
||||||
|
MagnetNyaaReverseProxy,
|
||||||
EnableComments,
|
EnableComments,
|
||||||
CustomAdFilterCode,
|
CustomAdFilterCode,
|
||||||
CustomAdFilterVersion,
|
CustomAdFilterVersion,
|
||||||
@@ -113,6 +114,7 @@ export async function POST(request: NextRequest) {
|
|||||||
MagnetMikanReverseProxy?: string;
|
MagnetMikanReverseProxy?: string;
|
||||||
MagnetDmhyReverseProxy?: string;
|
MagnetDmhyReverseProxy?: string;
|
||||||
MagnetAcgripReverseProxy?: string;
|
MagnetAcgripReverseProxy?: string;
|
||||||
|
MagnetNyaaReverseProxy?: string;
|
||||||
EnableComments: boolean;
|
EnableComments: boolean;
|
||||||
CustomAdFilterCode?: string;
|
CustomAdFilterCode?: string;
|
||||||
CustomAdFilterVersion?: number;
|
CustomAdFilterVersion?: number;
|
||||||
@@ -181,6 +183,8 @@ export async function POST(request: NextRequest) {
|
|||||||
typeof MagnetDmhyReverseProxy !== 'string') ||
|
typeof MagnetDmhyReverseProxy !== 'string') ||
|
||||||
(MagnetAcgripReverseProxy !== undefined &&
|
(MagnetAcgripReverseProxy !== undefined &&
|
||||||
typeof MagnetAcgripReverseProxy !== 'string') ||
|
typeof MagnetAcgripReverseProxy !== 'string') ||
|
||||||
|
(MagnetNyaaReverseProxy !== undefined &&
|
||||||
|
typeof MagnetNyaaReverseProxy !== 'string') ||
|
||||||
typeof EnableComments !== 'boolean' ||
|
typeof EnableComments !== 'boolean' ||
|
||||||
(CustomAdFilterCode !== undefined &&
|
(CustomAdFilterCode !== undefined &&
|
||||||
typeof CustomAdFilterCode !== 'string') ||
|
typeof CustomAdFilterCode !== 'string') ||
|
||||||
@@ -263,6 +267,7 @@ export async function POST(request: NextRequest) {
|
|||||||
MagnetMikanReverseProxy,
|
MagnetMikanReverseProxy,
|
||||||
MagnetDmhyReverseProxy,
|
MagnetDmhyReverseProxy,
|
||||||
MagnetAcgripReverseProxy,
|
MagnetAcgripReverseProxy,
|
||||||
|
MagnetNyaaReverseProxy,
|
||||||
EnableComments,
|
EnableComments,
|
||||||
CustomAdFilterCode,
|
CustomAdFilterCode,
|
||||||
CustomAdFilterVersion,
|
CustomAdFilterVersion,
|
||||||
|
|||||||
@@ -1775,6 +1775,12 @@ function SearchPageClient() {
|
|||||||
{activeTab === 'pansou' &&
|
{activeTab === 'pansou' &&
|
||||||
netdiskSearchEnabled &&
|
netdiskSearchEnabled &&
|
||||||
renderPansouCloudTypeFilter()}
|
renderPansouCloudTypeFilter()}
|
||||||
|
|
||||||
|
{activeTab === 'acg' && magnetSearchEnabled && (
|
||||||
|
<div className='mt-4'>
|
||||||
|
<AcgSearch keyword={searchQuery} controlsOnly />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pansouCloudFilterOpen &&
|
{pansouCloudFilterOpen &&
|
||||||
@@ -2162,6 +2168,7 @@ function SearchPageClient() {
|
|||||||
<AcgSearch
|
<AcgSearch
|
||||||
keyword={searchQuery}
|
keyword={searchQuery}
|
||||||
triggerSearch={triggerAcgSearch}
|
triggerSearch={triggerAcgSearch}
|
||||||
|
showSourceSwitch={false}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { AlertCircle, Download, ExternalLink, Loader2 } from 'lucide-react';
|
import { 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';
|
||||||
import Toast, { ToastProps } from '@/components/Toast';
|
import Toast, { ToastProps } from '@/components/Toast';
|
||||||
@@ -28,9 +28,11 @@ interface AcgSearchProps {
|
|||||||
keyword: string;
|
keyword: string;
|
||||||
triggerSearch?: boolean;
|
triggerSearch?: boolean;
|
||||||
onError?: (error: string) => void;
|
onError?: (error: string) => void;
|
||||||
|
controlsOnly?: boolean;
|
||||||
|
showSourceSwitch?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AcgSearchSource = 'acgrip' | 'mikan' | 'dmhy';
|
type AcgSearchSource = 'acgrip' | 'mikan' | 'dmhy' | 'nyaa';
|
||||||
type DownloadTool = 'aria2' | 'Transmission' | 'qBittorrent';
|
type DownloadTool = 'aria2' | 'Transmission' | 'qBittorrent';
|
||||||
|
|
||||||
const downloadToolOptions: Array<{ value: DownloadTool; label: string }> = [
|
const downloadToolOptions: Array<{ value: DownloadTool; label: string }> = [
|
||||||
@@ -39,12 +41,32 @@ const downloadToolOptions: Array<{ value: DownloadTool; label: string }> = [
|
|||||||
{ value: 'Transmission', label: 'Transmission' },
|
{ value: 'Transmission', label: 'Transmission' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const ACG_SOURCE_STORAGE_KEY = 'acgSearchSource';
|
||||||
|
const acgSourceOptions: Array<{ label: string; value: AcgSearchSource }> = [
|
||||||
|
{ label: 'ACG.RIP', value: 'acgrip' },
|
||||||
|
{ label: '蜜柑', value: 'mikan' },
|
||||||
|
{ label: '动漫花园', value: 'dmhy' },
|
||||||
|
{ label: 'Nyaa', value: 'nyaa' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getStoredAcgSource(): AcgSearchSource {
|
||||||
|
if (typeof window === 'undefined') return 'acgrip';
|
||||||
|
const saved = window.localStorage.getItem(ACG_SOURCE_STORAGE_KEY);
|
||||||
|
return acgSourceOptions.some((option) => option.value === saved)
|
||||||
|
? saved as AcgSearchSource
|
||||||
|
: 'acgrip';
|
||||||
|
}
|
||||||
|
|
||||||
export default function AcgSearch({
|
export default function AcgSearch({
|
||||||
keyword,
|
keyword,
|
||||||
triggerSearch,
|
triggerSearch,
|
||||||
onError,
|
onError,
|
||||||
|
controlsOnly = false,
|
||||||
|
showSourceSwitch = true,
|
||||||
}: AcgSearchProps) {
|
}: AcgSearchProps) {
|
||||||
const [source, setSource] = useState<AcgSearchSource>('acgrip');
|
const [source, setSource] = useState<AcgSearchSource>(() =>
|
||||||
|
getStoredAcgSource()
|
||||||
|
);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [allItems, setAllItems] = useState<AcgSearchItem[]>([]); // 所有加载的项目
|
const [allItems, setAllItems] = useState<AcgSearchItem[]>([]); // 所有加载的项目
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -60,11 +82,34 @@ export default function AcgSearch({
|
|||||||
const isLoadingMoreRef = useRef(false);
|
const isLoadingMoreRef = useRef(false);
|
||||||
const didInitSourceRef = useRef(false);
|
const didInitSourceRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSourceChange = (event: Event) => {
|
||||||
|
const nextSource = (event as CustomEvent<AcgSearchSource>).detail;
|
||||||
|
if (acgSourceOptions.some((option) => option.value === nextSource)) {
|
||||||
|
setSource(nextSource);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('acg-search-source-change', handleSourceChange);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('acg-search-source-change', handleSourceChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSourceChange = (value: AcgSearchSource) => {
|
||||||
|
setSource(value);
|
||||||
|
window.localStorage.setItem(ACG_SOURCE_STORAGE_KEY, value);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('acg-search-source-change', { detail: value })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 执行搜索
|
// 执行搜索
|
||||||
const performSearch = async (page: number, isLoadMore = false) => {
|
const performSearch = async (page: number, isLoadMore = false) => {
|
||||||
if (isLoadingMoreRef.current) return;
|
if (isLoadingMoreRef.current) return;
|
||||||
if (source === 'mikan' && page > 1) return;
|
if (source === 'mikan' && page > 1) return;
|
||||||
if (source === 'dmhy' && page > 1) return;
|
if (source === 'dmhy' && page > 1) return;
|
||||||
|
if (source === 'nyaa' && page > 1) return;
|
||||||
|
|
||||||
isLoadingMoreRef.current = true;
|
isLoadingMoreRef.current = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -76,7 +121,9 @@ export default function AcgSearch({
|
|||||||
? '/api/acg/mikan'
|
? '/api/acg/mikan'
|
||||||
: source === 'dmhy'
|
: source === 'dmhy'
|
||||||
? '/api/acg/dmhy'
|
? '/api/acg/dmhy'
|
||||||
: '/api/acg/acgrip';
|
: source === 'nyaa'
|
||||||
|
? '/api/acg/nyaa'
|
||||||
|
: '/api/acg/acgrip';
|
||||||
const response = await fetch(apiUrl, {
|
const response = await fetch(apiUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -97,14 +144,24 @@ export default function AcgSearch({
|
|||||||
|
|
||||||
if (isLoadMore) {
|
if (isLoadMore) {
|
||||||
// 追加新数据
|
// 追加新数据
|
||||||
setAllItems(prev => [...prev, ...data.items]);
|
setAllItems((prev) => [...prev, ...data.items]);
|
||||||
// 如果当前页没有结果,说明没有更多了
|
// 如果当前页没有结果,说明没有更多了
|
||||||
setHasMore(source !== 'mikan' && source !== 'dmhy' && data.items.length > 0);
|
setHasMore(
|
||||||
|
source !== 'mikan' &&
|
||||||
|
source !== 'dmhy' &&
|
||||||
|
source !== 'nyaa' &&
|
||||||
|
data.items.length > 0
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// 新搜索,重置数据
|
// 新搜索,重置数据
|
||||||
setAllItems(data.items);
|
setAllItems(data.items);
|
||||||
// 如果第一页有结果,假设可能还有更多
|
// 如果第一页有结果,假设可能还有更多
|
||||||
setHasMore(source !== 'mikan' && source !== 'dmhy' && data.items.length > 0);
|
setHasMore(
|
||||||
|
source !== 'mikan' &&
|
||||||
|
source !== 'dmhy' &&
|
||||||
|
source !== 'nyaa' &&
|
||||||
|
data.items.length > 0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
@@ -120,7 +177,7 @@ export default function AcgSearch({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// triggerSearch 变化时触发搜索(无论是 true 还是 false)
|
// triggerSearch 变化时触发搜索(无论是 true 还是 false)
|
||||||
if (triggerSearch === undefined) {
|
if (controlsOnly || triggerSearch === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,10 +191,12 @@ export default function AcgSearch({
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
setHasMore(true);
|
setHasMore(true);
|
||||||
performSearch(1, false);
|
performSearch(1, false);
|
||||||
}, [triggerSearch]);
|
}, [triggerSearch, controlsOnly]);
|
||||||
|
|
||||||
// 切换搜索源时,自动重新搜索(避免组件初次挂载时重复触发)
|
// 切换搜索源时,自动重新搜索(避免组件初次挂载时重复触发)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (controlsOnly) return;
|
||||||
|
|
||||||
if (!didInitSourceRef.current) {
|
if (!didInitSourceRef.current) {
|
||||||
didInitSourceRef.current = true;
|
didInitSourceRef.current = true;
|
||||||
return;
|
return;
|
||||||
@@ -150,12 +209,13 @@ export default function AcgSearch({
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
setHasMore(true);
|
setHasMore(true);
|
||||||
performSearch(1, false);
|
performSearch(1, false);
|
||||||
}, [source]);
|
}, [source, controlsOnly]);
|
||||||
|
|
||||||
// 加载更多数据
|
// 加载更多数据
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
if (source === 'mikan') return;
|
if (source === 'mikan') return;
|
||||||
if (source === 'dmhy') return;
|
if (source === 'dmhy') return;
|
||||||
|
if (source === 'nyaa') return;
|
||||||
if (!loading && hasMore && !isLoadingMoreRef.current) {
|
if (!loading && hasMore && !isLoadingMoreRef.current) {
|
||||||
performSearch(currentPage + 1, true);
|
performSearch(currentPage + 1, true);
|
||||||
}
|
}
|
||||||
@@ -260,7 +320,9 @@ export default function AcgSearch({
|
|||||||
<div className='flex items-center justify-center py-12'>
|
<div className='flex items-center justify-center py-12'>
|
||||||
<div className='text-center'>
|
<div className='text-center'>
|
||||||
<AlertCircle className='mx-auto h-12 w-12 text-red-500 dark:text-red-400' />
|
<AlertCircle className='mx-auto h-12 w-12 text-red-500 dark:text-red-400' />
|
||||||
<p className='mt-4 text-sm text-red-600 dark:text-red-400'>{error}</p>
|
<p className='mt-4 text-sm text-red-600 dark:text-red-400'>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -349,7 +411,10 @@ export default function AcgSearch({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 加载更多指示器 */}
|
{/* 加载更多指示器 */}
|
||||||
{source !== 'mikan' && source !== 'dmhy' && hasMore && (
|
{source !== 'mikan' &&
|
||||||
|
source !== 'dmhy' &&
|
||||||
|
source !== 'nyaa' &&
|
||||||
|
hasMore && (
|
||||||
<div ref={loadMoreRef} className='flex items-center justify-center py-8'>
|
<div ref={loadMoreRef} className='flex items-center justify-center py-8'>
|
||||||
<div className='text-center'>
|
<div className='text-center'>
|
||||||
<Loader2 className='mx-auto h-6 w-6 animate-spin text-green-600 dark:text-green-400' />
|
<Loader2 className='mx-auto h-6 w-6 animate-spin text-green-600 dark:text-green-400' />
|
||||||
@@ -416,20 +481,24 @@ export default function AcgSearch({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sourceSwitch = (
|
||||||
|
<div className='flex justify-center'>
|
||||||
|
<CapsuleSwitch
|
||||||
|
options={acgSourceOptions}
|
||||||
|
active={source}
|
||||||
|
onChange={(value) => handleSourceChange(value as AcgSearchSource)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (controlsOnly) {
|
||||||
|
return sourceSwitch;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
{/* 搜索源切换 */}
|
{/* 搜索源切换 */}
|
||||||
<div className='flex justify-center'>
|
{showSourceSwitch && sourceSwitch}
|
||||||
<CapsuleSwitch
|
|
||||||
options={[
|
|
||||||
{ label: 'ACG.RIP', value: 'acgrip' },
|
|
||||||
{ label: '蜜柑', value: 'mikan' },
|
|
||||||
{ label: '动漫花园', value: 'dmhy' },
|
|
||||||
]}
|
|
||||||
active={source}
|
|
||||||
onChange={(value) => setSource(value as AcgSearchSource)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{renderBody()}
|
{renderBody()}
|
||||||
|
|
||||||
{/* Toast 提示 */}
|
{/* Toast 提示 */}
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ export default function AnimeSubscriptionComponent({
|
|||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
title: '',
|
title: '',
|
||||||
filterText: '',
|
filterText: '',
|
||||||
source: 'mikan' as 'acgrip' | 'mikan' | 'dmhy',
|
source: 'mikan' as 'acgrip' | 'mikan' | 'dmhy' | 'nyaa',
|
||||||
lastEpisode: 0,
|
lastEpisode: 0,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
@@ -551,6 +551,7 @@ export default function AnimeSubscriptionComponent({
|
|||||||
<option value='mikan'>蜜柑 (Mikan)</option>
|
<option value='mikan'>蜜柑 (Mikan)</option>
|
||||||
<option value='acgrip'>ACG.RIP</option>
|
<option value='acgrip'>ACG.RIP</option>
|
||||||
<option value='dmhy'>动漫花园 (DMHY)</option>
|
<option value='dmhy'>动漫花园 (DMHY)</option>
|
||||||
|
<option value='nyaa'>Nyaa</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -619,7 +620,7 @@ export default function AnimeSubscriptionComponent({
|
|||||||
{sub.title}
|
{sub.title}
|
||||||
</h3>
|
</h3>
|
||||||
<span className='px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200'>
|
<span className='px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200'>
|
||||||
{sub.source === 'acgrip' ? 'ACG.RIP' : sub.source === 'mikan' ? '蜜柑' : '动漫花园'}
|
{sub.source === 'acgrip' ? 'ACG.RIP' : sub.source === 'mikan' ? '蜜柑' : sub.source === 'nyaa' ? 'Nyaa' : '动漫花园'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='text-sm text-gray-600 dark:text-gray-400 space-y-1'>
|
<div className='text-sm text-gray-600 dark:text-gray-400 space-y-1'>
|
||||||
@@ -675,7 +676,7 @@ export default function AnimeSubscriptionComponent({
|
|||||||
{sub.title}
|
{sub.title}
|
||||||
</h3>
|
</h3>
|
||||||
<span className='inline-block mt-1 px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200'>
|
<span className='inline-block mt-1 px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200'>
|
||||||
{sub.source === 'acgrip' ? 'ACG.RIP' : sub.source === 'mikan' ? '蜜柑' : '动漫花园'}
|
{sub.source === 'acgrip' ? 'ACG.RIP' : sub.source === 'mikan' ? '蜜柑' : sub.source === 'nyaa' ? 'Nyaa' : '动漫花园'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface AdminConfig {
|
|||||||
MagnetMikanReverseProxy?: string;
|
MagnetMikanReverseProxy?: string;
|
||||||
MagnetDmhyReverseProxy?: string;
|
MagnetDmhyReverseProxy?: string;
|
||||||
MagnetAcgripReverseProxy?: string;
|
MagnetAcgripReverseProxy?: string;
|
||||||
|
MagnetNyaaReverseProxy?: string;
|
||||||
// 评论功能开关
|
// 评论功能开关
|
||||||
EnableComments: boolean;
|
EnableComments: boolean;
|
||||||
// 自定义去广告代码
|
// 自定义去广告代码
|
||||||
@@ -358,7 +359,7 @@ export interface AdminConfig {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
filterText: string;
|
filterText: string;
|
||||||
source: 'acgrip' | 'mikan' | 'dmhy';
|
source: 'acgrip' | 'mikan' | 'dmhy' | 'nyaa';
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
lastCheckTime: number;
|
lastCheckTime: number;
|
||||||
lastEpisode: number;
|
lastEpisode: number;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import parseTorrentName from 'parse-torrent-name';
|
|||||||
import { parseStringPromise } from 'xml2js';
|
import { parseStringPromise } from 'xml2js';
|
||||||
|
|
||||||
import { getConfig, setCachedConfig } from '@/lib/config';
|
import { getConfig, setCachedConfig } from '@/lib/config';
|
||||||
|
import { getMagnetBaseUrl, universalMagnetFetch } from '@/lib/magnet.client';
|
||||||
import { db, getStorage } from '@/lib/db';
|
import { db, getStorage } from '@/lib/db';
|
||||||
import { EmailService } from '@/lib/email.service';
|
import { EmailService } from '@/lib/email.service';
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +15,14 @@ import { AnimeSubscription, AnimeSubscriptionDownloadTool } from '@/types/anime-
|
|||||||
|
|
||||||
const downloadTools: AnimeSubscriptionDownloadTool[] = ['aria2', 'qBittorrent', 'Transmission'];
|
const downloadTools: AnimeSubscriptionDownloadTool[] = ['aria2', 'qBittorrent', 'Transmission'];
|
||||||
|
|
||||||
|
const pickRssText = (value: any): string => {
|
||||||
|
if (value === undefined || value === null) return '';
|
||||||
|
const first = Array.isArray(value) ? value[0] : value;
|
||||||
|
if (first === undefined || first === null) return '';
|
||||||
|
if (typeof first === 'object') return String(first._ ?? first.$?.url ?? first.$?.href ?? '');
|
||||||
|
return String(first);
|
||||||
|
};
|
||||||
|
|
||||||
function getAnimeSubscriptionDownloadTool(tool: unknown): AnimeSubscriptionDownloadTool {
|
function getAnimeSubscriptionDownloadTool(tool: unknown): AnimeSubscriptionDownloadTool {
|
||||||
return typeof tool === 'string' && downloadTools.includes(tool as AnimeSubscriptionDownloadTool)
|
return typeof tool === 'string' && downloadTools.includes(tool as AnimeSubscriptionDownloadTool)
|
||||||
? tool as AnimeSubscriptionDownloadTool
|
? tool as AnimeSubscriptionDownloadTool
|
||||||
@@ -65,26 +74,50 @@ export function matchesFilter(title: string, filterText: string): boolean {
|
|||||||
*/
|
*/
|
||||||
export async function searchACG(
|
export async function searchACG(
|
||||||
keyword: string,
|
keyword: string,
|
||||||
source: 'acgrip' | 'mikan' | 'dmhy'
|
source: 'acgrip' | 'mikan' | 'dmhy' | 'nyaa'
|
||||||
) {
|
) {
|
||||||
const trimmedKeyword = keyword.trim();
|
const trimmedKeyword = keyword.trim();
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
let searchUrl: string;
|
let searchUrl: string;
|
||||||
|
|
||||||
switch (source) {
|
switch (source) {
|
||||||
case 'mikan':
|
case 'mikan': {
|
||||||
searchUrl = `https://mikanani.me/RSS/Search?searchstr=${encodeURIComponent(trimmedKeyword)}`;
|
const baseUrl = getMagnetBaseUrl(
|
||||||
|
'https://mikanani.me',
|
||||||
|
config.SiteConfig.MagnetMikanReverseProxy
|
||||||
|
);
|
||||||
|
searchUrl = `${baseUrl}/RSS/Search?searchstr=${encodeURIComponent(trimmedKeyword)}`;
|
||||||
break;
|
break;
|
||||||
case 'dmhy':
|
}
|
||||||
searchUrl = `http://share.dmhy.org/topics/rss/rss.xml?keyword=${encodeURIComponent(trimmedKeyword)}`;
|
case 'dmhy': {
|
||||||
|
const baseUrl = getMagnetBaseUrl(
|
||||||
|
'http://share.dmhy.org',
|
||||||
|
config.SiteConfig.MagnetDmhyReverseProxy
|
||||||
|
);
|
||||||
|
searchUrl = `${baseUrl}/topics/rss/rss.xml?keyword=${encodeURIComponent(trimmedKeyword)}`;
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
case 'nyaa': {
|
||||||
|
const baseUrl = getMagnetBaseUrl(
|
||||||
|
'https://nyaa.si',
|
||||||
|
config.SiteConfig.MagnetNyaaReverseProxy
|
||||||
|
);
|
||||||
|
searchUrl = `${baseUrl}/?page=rss&q=${encodeURIComponent(trimmedKeyword)}&c=1_0&f=0`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'acgrip':
|
case 'acgrip':
|
||||||
default:
|
default: {
|
||||||
searchUrl = `https://acg.rip/page/1.xml?term=${encodeURIComponent(trimmedKeyword)}`;
|
const baseUrl = getMagnetBaseUrl(
|
||||||
|
'https://acg.rip',
|
||||||
|
config.SiteConfig.MagnetAcgripReverseProxy
|
||||||
|
);
|
||||||
|
searchUrl = `${baseUrl}/page/1.xml?term=${encodeURIComponent(trimmedKeyword)}`;
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(searchUrl, {
|
const response = await universalMagnetFetch(searchUrl, config.SiteConfig.MagnetProxy, {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent':
|
'User-Agent':
|
||||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
@@ -104,14 +137,21 @@ export async function searchACG(
|
|||||||
|
|
||||||
const items = parsed.rss.channel[0].item;
|
const items = parsed.rss.channel[0].item;
|
||||||
|
|
||||||
// 统一格式
|
// 统一格式。注意:Nyaa RSS 的 link 是 .torrent 下载地址,guid 才是详情页。
|
||||||
return items.map((item: any) => {
|
return items.map((item: any) => {
|
||||||
const title = item.title?.[0] || '';
|
const title = pickRssText(item.title);
|
||||||
const link = item.link?.[0] || '';
|
const rawLink = pickRssText(item.link);
|
||||||
const guid = item.guid?.[0] || link || `${title}-${item.pubDate?.[0] || ''}`;
|
const rawGuid = pickRssText(item.guid);
|
||||||
const pubDate = item.pubDate?.[0] || '';
|
const pubDate = pickRssText(item.pubDate);
|
||||||
const torrentUrl = item.enclosure?.[0]?.$?.url || '';
|
const description = pickRssText(item.description) || pickRssText(item['content:encoded']);
|
||||||
const description = item.description?.[0] || '';
|
const enclosureUrl =
|
||||||
|
pickRssText(item.enclosure?.[0]?.$?.url) ||
|
||||||
|
pickRssText(item.enclosure?.[0]?.$?.href);
|
||||||
|
|
||||||
|
const isNyaa = source === 'nyaa';
|
||||||
|
const link = isNyaa ? (rawGuid || rawLink) : rawLink;
|
||||||
|
const torrentUrl = isNyaa ? rawLink : enclosureUrl;
|
||||||
|
const guid = rawGuid || link || torrentUrl || `${title}-${pubDate}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
@@ -237,7 +277,7 @@ async function sendAnimeUpdateNotifications(
|
|||||||
<div style="background-color: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
<div style="background-color: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||||
<h3 style="margin-top: 0; color: #2563eb;">${subscription.title}</h3>
|
<h3 style="margin-top: 0; color: #2563eb;">${subscription.title}</h3>
|
||||||
<p style="margin: 10px 0;">新增集数:第 ${episodeList} 集</p>
|
<p style="margin: 10px 0;">新增集数:第 ${episodeList} 集</p>
|
||||||
<p style="margin: 10px 0; color: #666;">搜索源:${subscription.source === 'acgrip' ? 'ACG.RIP' : subscription.source === 'mikan' ? '蜜柑' : '动漫花园'}</p>
|
<p style="margin: 10px 0; color: #666;">搜索源:${subscription.source === 'acgrip' ? 'ACG.RIP' : subscription.source === 'mikan' ? '蜜柑' : subscription.source === 'nyaa' ? 'Nyaa' : '动漫花园'}</p>
|
||||||
</div>
|
</div>
|
||||||
<p style="color: #666; font-size: 14px;">这些集数已自动添加到 OpenList 离线下载队列。</p>
|
<p style="color: #666; font-size: 14px;">这些集数已自动添加到 OpenList 离线下载队列。</p>
|
||||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ async function getInitConfig(
|
|||||||
MagnetMikanReverseProxy: '',
|
MagnetMikanReverseProxy: '',
|
||||||
MagnetDmhyReverseProxy: '',
|
MagnetDmhyReverseProxy: '',
|
||||||
MagnetAcgripReverseProxy: '',
|
MagnetAcgripReverseProxy: '',
|
||||||
|
MagnetNyaaReverseProxy: '',
|
||||||
// 评论功能开关
|
// 评论功能开关
|
||||||
EnableComments: false,
|
EnableComments: false,
|
||||||
EnableRegistration: false,
|
EnableRegistration: false,
|
||||||
@@ -509,6 +510,7 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
|||||||
MagnetMikanReverseProxy: '',
|
MagnetMikanReverseProxy: '',
|
||||||
MagnetDmhyReverseProxy: '',
|
MagnetDmhyReverseProxy: '',
|
||||||
MagnetAcgripReverseProxy: '',
|
MagnetAcgripReverseProxy: '',
|
||||||
|
MagnetNyaaReverseProxy: '',
|
||||||
EnableComments: false,
|
EnableComments: false,
|
||||||
EnableRegistration: false,
|
EnableRegistration: false,
|
||||||
RequireRegistrationInviteCode: false,
|
RequireRegistrationInviteCode: false,
|
||||||
@@ -579,6 +581,9 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
|||||||
if (adminConfig.SiteConfig.MagnetAcgripReverseProxy === undefined) {
|
if (adminConfig.SiteConfig.MagnetAcgripReverseProxy === undefined) {
|
||||||
adminConfig.SiteConfig.MagnetAcgripReverseProxy = '';
|
adminConfig.SiteConfig.MagnetAcgripReverseProxy = '';
|
||||||
}
|
}
|
||||||
|
if (adminConfig.SiteConfig.MagnetNyaaReverseProxy === undefined) {
|
||||||
|
adminConfig.SiteConfig.MagnetNyaaReverseProxy = '';
|
||||||
|
}
|
||||||
if (!adminConfig.UserConfig) {
|
if (!adminConfig.UserConfig) {
|
||||||
adminConfig.UserConfig = { Users: [] };
|
adminConfig.UserConfig = { Users: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export interface AnimeSubscription {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
filterText: string;
|
filterText: string;
|
||||||
source: 'acgrip' | 'mikan' | 'dmhy';
|
source: 'acgrip' | 'mikan' | 'dmhy' | 'nyaa';
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
lastCheckTime: number;
|
lastCheckTime: number;
|
||||||
lastEpisode: number;
|
lastEpisode: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user