动漫磁力搜索增加nyaa;动漫磁力搜索前可选站点
This commit is contained in:
@@ -385,6 +385,7 @@ interface SiteConfig {
|
||||
MagnetMikanReverseProxy?: string;
|
||||
MagnetDmhyReverseProxy?: string;
|
||||
MagnetAcgripReverseProxy?: string;
|
||||
MagnetNyaaReverseProxy?: string;
|
||||
EnableComments: boolean;
|
||||
EnableRegistration?: boolean;
|
||||
RequireRegistrationInviteCode?: boolean;
|
||||
@@ -10151,6 +10152,7 @@ const SiteConfigComponent = ({
|
||||
MagnetMikanReverseProxy: '',
|
||||
MagnetDmhyReverseProxy: '',
|
||||
MagnetAcgripReverseProxy: '',
|
||||
MagnetNyaaReverseProxy: '',
|
||||
EnableComments: false,
|
||||
EnableRegistration: false,
|
||||
RegistrationRequireTurnstile: false,
|
||||
@@ -10273,6 +10275,7 @@ const SiteConfigComponent = ({
|
||||
MagnetDmhyReverseProxy: config.SiteConfig.MagnetDmhyReverseProxy || '',
|
||||
MagnetAcgripReverseProxy:
|
||||
config.SiteConfig.MagnetAcgripReverseProxy || '',
|
||||
MagnetNyaaReverseProxy: config.SiteConfig.MagnetNyaaReverseProxy || '',
|
||||
EnableComments: config.SiteConfig.EnableComments || false,
|
||||
});
|
||||
}
|
||||
@@ -11278,6 +11281,27 @@ const SiteConfigComponent = ({
|
||||
配置后将使用该地址替代默认的 ACG.RIP 域名进行请求。
|
||||
</p>
|
||||
</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>
|
||||
</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();
|
||||
}
|
||||
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 });
|
||||
}
|
||||
subscription.source = updates.source;
|
||||
|
||||
@@ -61,7 +61,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// 验证 source
|
||||
if (!['acgrip', 'mikan', 'dmhy'].includes(source)) {
|
||||
if (!['acgrip', 'mikan', 'dmhy', 'nyaa'].includes(source)) {
|
||||
return NextResponse.json({ error: '无效的搜索源' }, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ export async function POST(request: NextRequest) {
|
||||
MagnetMikanReverseProxy,
|
||||
MagnetDmhyReverseProxy,
|
||||
MagnetAcgripReverseProxy,
|
||||
MagnetNyaaReverseProxy,
|
||||
EnableComments,
|
||||
CustomAdFilterCode,
|
||||
CustomAdFilterVersion,
|
||||
@@ -113,6 +114,7 @@ export async function POST(request: NextRequest) {
|
||||
MagnetMikanReverseProxy?: string;
|
||||
MagnetDmhyReverseProxy?: string;
|
||||
MagnetAcgripReverseProxy?: string;
|
||||
MagnetNyaaReverseProxy?: string;
|
||||
EnableComments: boolean;
|
||||
CustomAdFilterCode?: string;
|
||||
CustomAdFilterVersion?: number;
|
||||
@@ -181,6 +183,8 @@ export async function POST(request: NextRequest) {
|
||||
typeof MagnetDmhyReverseProxy !== 'string') ||
|
||||
(MagnetAcgripReverseProxy !== undefined &&
|
||||
typeof MagnetAcgripReverseProxy !== 'string') ||
|
||||
(MagnetNyaaReverseProxy !== undefined &&
|
||||
typeof MagnetNyaaReverseProxy !== 'string') ||
|
||||
typeof EnableComments !== 'boolean' ||
|
||||
(CustomAdFilterCode !== undefined &&
|
||||
typeof CustomAdFilterCode !== 'string') ||
|
||||
@@ -263,6 +267,7 @@ export async function POST(request: NextRequest) {
|
||||
MagnetMikanReverseProxy,
|
||||
MagnetDmhyReverseProxy,
|
||||
MagnetAcgripReverseProxy,
|
||||
MagnetNyaaReverseProxy,
|
||||
EnableComments,
|
||||
CustomAdFilterCode,
|
||||
CustomAdFilterVersion,
|
||||
|
||||
@@ -1775,6 +1775,12 @@ function SearchPageClient() {
|
||||
{activeTab === 'pansou' &&
|
||||
netdiskSearchEnabled &&
|
||||
renderPansouCloudTypeFilter()}
|
||||
|
||||
{activeTab === 'acg' && magnetSearchEnabled && (
|
||||
<div className='mt-4'>
|
||||
<AcgSearch keyword={searchQuery} controlsOnly />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pansouCloudFilterOpen &&
|
||||
@@ -2162,6 +2168,7 @@ function SearchPageClient() {
|
||||
<AcgSearch
|
||||
keyword={searchQuery}
|
||||
triggerSearch={triggerAcgSearch}
|
||||
showSourceSwitch={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
'use client';
|
||||
|
||||
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 Toast, { ToastProps } from '@/components/Toast';
|
||||
@@ -28,9 +28,11 @@ interface AcgSearchProps {
|
||||
keyword: string;
|
||||
triggerSearch?: boolean;
|
||||
onError?: (error: string) => void;
|
||||
controlsOnly?: boolean;
|
||||
showSourceSwitch?: boolean;
|
||||
}
|
||||
|
||||
type AcgSearchSource = 'acgrip' | 'mikan' | 'dmhy';
|
||||
type AcgSearchSource = 'acgrip' | 'mikan' | 'dmhy' | 'nyaa';
|
||||
type DownloadTool = 'aria2' | 'Transmission' | 'qBittorrent';
|
||||
|
||||
const downloadToolOptions: Array<{ value: DownloadTool; label: string }> = [
|
||||
@@ -39,12 +41,32 @@ const downloadToolOptions: Array<{ value: DownloadTool; label: string }> = [
|
||||
{ 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({
|
||||
keyword,
|
||||
triggerSearch,
|
||||
onError,
|
||||
controlsOnly = false,
|
||||
showSourceSwitch = true,
|
||||
}: AcgSearchProps) {
|
||||
const [source, setSource] = useState<AcgSearchSource>('acgrip');
|
||||
const [source, setSource] = useState<AcgSearchSource>(() =>
|
||||
getStoredAcgSource()
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [allItems, setAllItems] = useState<AcgSearchItem[]>([]); // 所有加载的项目
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -60,11 +82,34 @@ export default function AcgSearch({
|
||||
const isLoadingMoreRef = 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) => {
|
||||
if (isLoadingMoreRef.current) return;
|
||||
if (source === 'mikan' && page > 1) return;
|
||||
if (source === 'dmhy' && page > 1) return;
|
||||
if (source === 'nyaa' && page > 1) return;
|
||||
|
||||
isLoadingMoreRef.current = true;
|
||||
setLoading(true);
|
||||
@@ -76,7 +121,9 @@ export default function AcgSearch({
|
||||
? '/api/acg/mikan'
|
||||
: source === 'dmhy'
|
||||
? '/api/acg/dmhy'
|
||||
: '/api/acg/acgrip';
|
||||
: source === 'nyaa'
|
||||
? '/api/acg/nyaa'
|
||||
: '/api/acg/acgrip';
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -97,14 +144,24 @@ export default function AcgSearch({
|
||||
|
||||
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 {
|
||||
// 新搜索,重置数据
|
||||
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);
|
||||
@@ -120,7 +177,7 @@ export default function AcgSearch({
|
||||
|
||||
useEffect(() => {
|
||||
// triggerSearch 变化时触发搜索(无论是 true 还是 false)
|
||||
if (triggerSearch === undefined) {
|
||||
if (controlsOnly || triggerSearch === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -134,10 +191,12 @@ export default function AcgSearch({
|
||||
setCurrentPage(1);
|
||||
setHasMore(true);
|
||||
performSearch(1, false);
|
||||
}, [triggerSearch]);
|
||||
}, [triggerSearch, controlsOnly]);
|
||||
|
||||
// 切换搜索源时,自动重新搜索(避免组件初次挂载时重复触发)
|
||||
useEffect(() => {
|
||||
if (controlsOnly) return;
|
||||
|
||||
if (!didInitSourceRef.current) {
|
||||
didInitSourceRef.current = true;
|
||||
return;
|
||||
@@ -150,12 +209,13 @@ export default function AcgSearch({
|
||||
setCurrentPage(1);
|
||||
setHasMore(true);
|
||||
performSearch(1, false);
|
||||
}, [source]);
|
||||
}, [source, controlsOnly]);
|
||||
|
||||
// 加载更多数据
|
||||
const loadMore = useCallback(() => {
|
||||
if (source === 'mikan') return;
|
||||
if (source === 'dmhy') return;
|
||||
if (source === 'nyaa') return;
|
||||
if (!loading && hasMore && !isLoadingMoreRef.current) {
|
||||
performSearch(currentPage + 1, true);
|
||||
}
|
||||
@@ -260,7 +320,9 @@ export default function AcgSearch({
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<div className='text-center'>
|
||||
<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>
|
||||
);
|
||||
@@ -349,7 +411,10 @@ export default function AcgSearch({
|
||||
</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 className='text-center'>
|
||||
<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 (
|
||||
<div className='space-y-6'>
|
||||
{/* 搜索源切换 */}
|
||||
<div className='flex justify-center'>
|
||||
<CapsuleSwitch
|
||||
options={[
|
||||
{ label: 'ACG.RIP', value: 'acgrip' },
|
||||
{ label: '蜜柑', value: 'mikan' },
|
||||
{ label: '动漫花园', value: 'dmhy' },
|
||||
]}
|
||||
active={source}
|
||||
onChange={(value) => setSource(value as AcgSearchSource)}
|
||||
/>
|
||||
</div>
|
||||
{showSourceSwitch && sourceSwitch}
|
||||
{renderBody()}
|
||||
|
||||
{/* Toast 提示 */}
|
||||
|
||||
@@ -179,7 +179,7 @@ export default function AnimeSubscriptionComponent({
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
filterText: '',
|
||||
source: 'mikan' as 'acgrip' | 'mikan' | 'dmhy',
|
||||
source: 'mikan' as 'acgrip' | 'mikan' | 'dmhy' | 'nyaa',
|
||||
lastEpisode: 0,
|
||||
enabled: true,
|
||||
});
|
||||
@@ -551,6 +551,7 @@ export default function AnimeSubscriptionComponent({
|
||||
<option value='mikan'>蜜柑 (Mikan)</option>
|
||||
<option value='acgrip'>ACG.RIP</option>
|
||||
<option value='dmhy'>动漫花园 (DMHY)</option>
|
||||
<option value='nyaa'>Nyaa</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -619,7 +620,7 @@ export default function AnimeSubscriptionComponent({
|
||||
{sub.title}
|
||||
</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'>
|
||||
{sub.source === 'acgrip' ? 'ACG.RIP' : sub.source === 'mikan' ? '蜜柑' : '动漫花园'}
|
||||
{sub.source === 'acgrip' ? 'ACG.RIP' : sub.source === 'mikan' ? '蜜柑' : sub.source === 'nyaa' ? 'Nyaa' : '动漫花园'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='text-sm text-gray-600 dark:text-gray-400 space-y-1'>
|
||||
@@ -675,7 +676,7 @@ export default function AnimeSubscriptionComponent({
|
||||
{sub.title}
|
||||
</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'>
|
||||
{sub.source === 'acgrip' ? 'ACG.RIP' : sub.source === 'mikan' ? '蜜柑' : '动漫花园'}
|
||||
{sub.source === 'acgrip' ? 'ACG.RIP' : sub.source === 'mikan' ? '蜜柑' : sub.source === 'nyaa' ? 'Nyaa' : '动漫花园'}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface AdminConfig {
|
||||
MagnetMikanReverseProxy?: string;
|
||||
MagnetDmhyReverseProxy?: string;
|
||||
MagnetAcgripReverseProxy?: string;
|
||||
MagnetNyaaReverseProxy?: string;
|
||||
// 评论功能开关
|
||||
EnableComments: boolean;
|
||||
// 自定义去广告代码
|
||||
@@ -358,7 +359,7 @@ export interface AdminConfig {
|
||||
id: string;
|
||||
title: string;
|
||||
filterText: string;
|
||||
source: 'acgrip' | 'mikan' | 'dmhy';
|
||||
source: 'acgrip' | 'mikan' | 'dmhy' | 'nyaa';
|
||||
enabled: boolean;
|
||||
lastCheckTime: number;
|
||||
lastEpisode: number;
|
||||
|
||||
@@ -3,6 +3,7 @@ import parseTorrentName from 'parse-torrent-name';
|
||||
import { parseStringPromise } from 'xml2js';
|
||||
|
||||
import { getConfig, setCachedConfig } from '@/lib/config';
|
||||
import { getMagnetBaseUrl, universalMagnetFetch } from '@/lib/magnet.client';
|
||||
import { db, getStorage } from '@/lib/db';
|
||||
import { EmailService } from '@/lib/email.service';
|
||||
import {
|
||||
@@ -14,6 +15,14 @@ import { AnimeSubscription, AnimeSubscriptionDownloadTool } from '@/types/anime-
|
||||
|
||||
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 {
|
||||
return typeof tool === 'string' && downloadTools.includes(tool as AnimeSubscriptionDownloadTool)
|
||||
? tool as AnimeSubscriptionDownloadTool
|
||||
@@ -65,26 +74,50 @@ export function matchesFilter(title: string, filterText: string): boolean {
|
||||
*/
|
||||
export async function searchACG(
|
||||
keyword: string,
|
||||
source: 'acgrip' | 'mikan' | 'dmhy'
|
||||
source: 'acgrip' | 'mikan' | 'dmhy' | 'nyaa'
|
||||
) {
|
||||
const trimmedKeyword = keyword.trim();
|
||||
const config = await getConfig();
|
||||
|
||||
let searchUrl: string;
|
||||
|
||||
switch (source) {
|
||||
case 'mikan':
|
||||
searchUrl = `https://mikanani.me/RSS/Search?searchstr=${encodeURIComponent(trimmedKeyword)}`;
|
||||
case 'mikan': {
|
||||
const baseUrl = getMagnetBaseUrl(
|
||||
'https://mikanani.me',
|
||||
config.SiteConfig.MagnetMikanReverseProxy
|
||||
);
|
||||
searchUrl = `${baseUrl}/RSS/Search?searchstr=${encodeURIComponent(trimmedKeyword)}`;
|
||||
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;
|
||||
}
|
||||
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':
|
||||
default:
|
||||
searchUrl = `https://acg.rip/page/1.xml?term=${encodeURIComponent(trimmedKeyword)}`;
|
||||
default: {
|
||||
const baseUrl = getMagnetBaseUrl(
|
||||
'https://acg.rip',
|
||||
config.SiteConfig.MagnetAcgripReverseProxy
|
||||
);
|
||||
searchUrl = `${baseUrl}/page/1.xml?term=${encodeURIComponent(trimmedKeyword)}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(searchUrl, {
|
||||
const response = await universalMagnetFetch(searchUrl, config.SiteConfig.MagnetProxy, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'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;
|
||||
|
||||
// 统一格式
|
||||
// 统一格式。注意:Nyaa RSS 的 link 是 .torrent 下载地址,guid 才是详情页。
|
||||
return items.map((item: any) => {
|
||||
const title = item.title?.[0] || '';
|
||||
const link = item.link?.[0] || '';
|
||||
const guid = item.guid?.[0] || link || `${title}-${item.pubDate?.[0] || ''}`;
|
||||
const pubDate = item.pubDate?.[0] || '';
|
||||
const torrentUrl = item.enclosure?.[0]?.$?.url || '';
|
||||
const description = item.description?.[0] || '';
|
||||
const title = pickRssText(item.title);
|
||||
const rawLink = pickRssText(item.link);
|
||||
const rawGuid = pickRssText(item.guid);
|
||||
const pubDate = pickRssText(item.pubDate);
|
||||
const description = pickRssText(item.description) || pickRssText(item['content:encoded']);
|
||||
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 {
|
||||
title,
|
||||
@@ -237,7 +277,7 @@ async function sendAnimeUpdateNotifications(
|
||||
<div style="background-color: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<h3 style="margin-top: 0; color: #2563eb;">${subscription.title}</h3>
|
||||
<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>
|
||||
<p style="color: #666; font-size: 14px;">这些集数已自动添加到 OpenList 离线下载队列。</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
|
||||
@@ -313,6 +313,7 @@ async function getInitConfig(
|
||||
MagnetMikanReverseProxy: '',
|
||||
MagnetDmhyReverseProxy: '',
|
||||
MagnetAcgripReverseProxy: '',
|
||||
MagnetNyaaReverseProxy: '',
|
||||
// 评论功能开关
|
||||
EnableComments: false,
|
||||
EnableRegistration: false,
|
||||
@@ -509,6 +510,7 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
MagnetMikanReverseProxy: '',
|
||||
MagnetDmhyReverseProxy: '',
|
||||
MagnetAcgripReverseProxy: '',
|
||||
MagnetNyaaReverseProxy: '',
|
||||
EnableComments: false,
|
||||
EnableRegistration: false,
|
||||
RequireRegistrationInviteCode: false,
|
||||
@@ -579,6 +581,9 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
if (adminConfig.SiteConfig.MagnetAcgripReverseProxy === undefined) {
|
||||
adminConfig.SiteConfig.MagnetAcgripReverseProxy = '';
|
||||
}
|
||||
if (adminConfig.SiteConfig.MagnetNyaaReverseProxy === undefined) {
|
||||
adminConfig.SiteConfig.MagnetNyaaReverseProxy = '';
|
||||
}
|
||||
if (!adminConfig.UserConfig) {
|
||||
adminConfig.UserConfig = { Users: [] };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ export interface AnimeSubscription {
|
||||
id: string;
|
||||
title: string;
|
||||
filterText: string;
|
||||
source: 'acgrip' | 'mikan' | 'dmhy';
|
||||
source: 'acgrip' | 'mikan' | 'dmhy' | 'nyaa';
|
||||
enabled: boolean;
|
||||
lastCheckTime: number;
|
||||
lastEpisode: number;
|
||||
|
||||
Reference in New Issue
Block a user