diff --git a/package.json b/package.json index 43b90d3..ff538eb 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "swiper": "^11.2.8", "tailwind-merge": "^2.6.0", "vidstack": "^0.6.15", + "xml2js": "^0.6.2", "zod": "^3.24.1" }, "devDependencies": { @@ -73,6 +74,7 @@ "@types/react": "^18.3.18", "@types/react-dom": "^19.1.6", "@types/testing-library__jest-dom": "^5.14.9", + "@types/xml2js": "^0.4.14", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "autoprefixer": "^10.4.20", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6323be9..4d14143 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,6 +119,9 @@ importers: vidstack: specifier: ^0.6.15 version: 0.6.15 + xml2js: + specifier: ^0.6.2 + version: 0.6.2 zod: specifier: ^3.24.1 version: 3.25.67 @@ -162,6 +165,9 @@ importers: '@types/testing-library__jest-dom': specifier: ^5.14.9 version: 5.14.9 + '@types/xml2js': + specifier: ^0.4.14 + version: 0.4.14 '@typescript-eslint/eslint-plugin': specifier: ^5.62.0 version: 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5) @@ -1182,28 +1188,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@14.2.33': resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@14.2.33': resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@14.2.33': resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@14.2.33': resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==} @@ -1620,6 +1622,9 @@ packages: '@types/validator@13.15.10': resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + '@types/xml2js@0.4.14': + resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -1729,49 +1734,41 @@ packages: resolution: {integrity: sha512-vdqBh911wc5awE2bX2zx3eflbyv8U9xbE/jVKAm425eRoOVv/VseGZsqi3A3SykckSpF4wSROkbQPvbQFn8EsA==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.9.0': resolution: {integrity: sha512-/8JFZ/SnuDr1lLEVsxsuVwrsGquTvT51RZGvyDB/dOK3oYK2UqeXzgeyq6Otp8FZXQcEYqJwxb9v+gtdXn03eQ==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.9.0': resolution: {integrity: sha512-FkJjybtrl+rajTw4loI3L6YqSOpeZfDls4SstL/5lsP2bka9TiHUjgMBjygeZEis1oC8LfJTS8FSgpKPaQx2tQ==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.9.0': resolution: {integrity: sha512-w/NZfHNeDusbqSZ8r/hp8iL4S39h4+vQMc9/vvzuIKMWKppyUGKm3IST0Qv0aOZ1rzIbl9SrDeIqK86ZpUK37w==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.9.0': resolution: {integrity: sha512-bEPBosut8/8KQbUixPry8zg/fOzVOWyvwzOfz0C0Rw6dp+wIBseyiHKjkcSyZKv/98edrbMknBaMNJfA/UEdqw==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.9.0': resolution: {integrity: sha512-LDtMT7moE3gK753gG4pc31AAqGUC86j3AplaFusc717EUGF9ZFJ356sdQzzZzkBk1XzMdxFyZ4f/i35NKM/lFA==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.9.0': resolution: {integrity: sha512-WmFd5KINHIXj8o1mPaT8QRjA9HgSXhN1gl9Da4IZihARihEnOylu4co7i/yeaIpcfsI6sYs33cNZKyHYDh0lrA==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.9.0': resolution: {integrity: sha512-CYuXbANW+WgzVRIl8/QvZmDaZxrqvOldOwlbUjIM4pQ46FJ0W5cinJ/Ghwa/Ng1ZPMJMk1VFdsD/XwmCGIXBWg==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.9.0': resolution: {integrity: sha512-6Rp2WH0OoitMYR57Z6VE8Y6corX8C6QEMWLgOV6qXiJIeZ1F9WGXY/yQ8yDC4iTraotyLOeJ2Asea0urWj2fKQ==} @@ -4666,6 +4663,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sax@1.4.3: + resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==} + saxes@5.0.1: resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==} engines: {node: '>=10'} @@ -5499,6 +5499,14 @@ packages: xml-name-validator@3.0.0: resolution: {integrity: sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==} + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -7305,6 +7313,10 @@ snapshots: '@types/validator@13.15.10': {} + '@types/xml2js@0.4.14': + dependencies: + '@types/node': 24.0.3 + '@types/yargs-parser@21.0.3': {} '@types/yargs@16.0.9': @@ -10812,6 +10824,8 @@ snapshots: safer-buffer@2.1.2: {} + sax@1.4.3: {} + saxes@5.0.1: dependencies: xmlchars: 2.2.0 @@ -11845,6 +11859,13 @@ snapshots: xml-name-validator@3.0.0: {} + xml2js@0.6.2: + dependencies: + sax: 1.4.3 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + xmlchars@2.2.0: {} xmlhttprequest-ssl@2.1.2: {} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index c3f25ec..11d2095 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -2762,6 +2762,7 @@ const OpenListConfigComponent = ({ const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [rootPath, setRootPath] = useState('/'); + const [offlineDownloadPath, setOfflineDownloadPath] = useState('/'); const [scanInterval, setScanInterval] = useState(0); const [videos, setVideos] = useState([]); const [refreshing, setRefreshing] = useState(false); @@ -2780,6 +2781,7 @@ const OpenListConfigComponent = ({ setUsername(config.OpenListConfig.Username || ''); setPassword(config.OpenListConfig.Password || ''); setRootPath(config.OpenListConfig.RootPath || '/'); + setOfflineDownloadPath(config.OpenListConfig.OfflineDownloadPath || '/'); setScanInterval(config.OpenListConfig.ScanInterval || 0); } }, [config]); @@ -2819,6 +2821,7 @@ const OpenListConfigComponent = ({ Username: username, Password: password, RootPath: rootPath, + OfflineDownloadPath: offlineDownloadPath, ScanInterval: scanInterval, }), }); @@ -3111,6 +3114,23 @@ const OpenListConfigComponent = ({

+
+ + setOfflineDownloadPath(e.target.value)} + disabled={!enabled} + placeholder='/' + 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-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed' + /> +

+ 动漫磁力等离线下载任务的保存目录,默认为根目录 / +

+
+
@@ -975,7 +1055,7 @@ function SearchPageClient() { )} - ) : ( + ) : activeTab === 'pansou' ? ( <> {/* 网盘搜索结果 */}
@@ -988,6 +1068,19 @@ function SearchPageClient() { triggerSearch={triggerPansouSearch} /> + ) : ( + <> + {/* ACG 磁力搜索结果 */} +
+

+ 动漫磁力搜索结果 +

+
+ + )} ) : searchHistory.length > 0 ? ( @@ -1018,11 +1111,20 @@ function SearchPageClient() { if (activeTab === 'video') { // 影视搜索 router.push( - `/search?q=${encodeURIComponent(item.trim())}` + `/search?q=${encodeURIComponent(item.trim())}&type=video` ); } else if (activeTab === 'pansou') { // 网盘搜索 + router.push( + `/search?q=${encodeURIComponent(item.trim())}&type=pansou` + ); setTriggerPansouSearch(prev => !prev); + } else if (activeTab === 'acg') { + // ACG 磁力搜索 + router.push( + `/search?q=${encodeURIComponent(item.trim())}&type=acg` + ); + setTriggerAcgSearch(prev => !prev); } }} className='px-4 py-2 bg-gray-500/10 hover:bg-gray-300 rounded-full text-sm text-gray-700 transition-colors duration-200 dark:bg-gray-700/50 dark:hover:bg-gray-600 dark:text-gray-300' diff --git a/src/components/AcgSearch.tsx b/src/components/AcgSearch.tsx new file mode 100644 index 0000000..31a14de --- /dev/null +++ b/src/components/AcgSearch.tsx @@ -0,0 +1,327 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +'use client'; + +import { AlertCircle, Download, ExternalLink, Loader2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +import Toast, { ToastProps } from '@/components/Toast'; + +interface AcgSearchItem { + title: string; + link: string; + guid: string; + pubDate: string; + torrentUrl: string; + description: string; + images: string[]; +} + +interface AcgSearchResult { + keyword: string; + page: number; + total: number; + items: AcgSearchItem[]; +} + +interface AcgSearchProps { + keyword: string; + triggerSearch?: boolean; + onError?: (error: string) => void; +} + +export default function AcgSearch({ + keyword, + triggerSearch, + onError, +}: AcgSearchProps) { + const [loading, setLoading] = useState(false); + const [results, setResults] = useState(null); + const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [downloadingId, setDownloadingId] = useState(null); + const [showNameDialog, setShowNameDialog] = useState(false); + const [selectedItem, setSelectedItem] = useState(null); + const [customName, setCustomName] = useState(''); + const [toast, setToast] = useState(null); + + // 执行搜索 + const performSearch = async (page: number) => { + setLoading(true); + setError(null); + + try { + const response = await fetch('/api/acg/search', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + keyword: keyword.trim(), + page, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || '搜索失败'); + } + + const data: AcgSearchResult = await response.json(); + setResults(data); + setCurrentPage(page); + } catch (err: any) { + const errorMsg = err.message || '搜索失败,请稍后重试'; + setError(errorMsg); + onError?.(errorMsg); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + // triggerSearch 变化时触发搜索(无论是 true 还是 false) + if (triggerSearch === undefined) { + return; + } + + const currentKeyword = keyword.trim(); + if (!currentKeyword) { + return; + } + + performSearch(1); + }, [triggerSearch]); + + // 处理页码变化 + const handlePageChange = (newPage: number) => { + if (newPage < 1 || loading) return; + performSearch(newPage); + }; + + // 打开命名弹窗 + const handleOpenDownloadDialog = (item: AcgSearchItem) => { + setSelectedItem(item); + setCustomName(keyword.trim()); + setShowNameDialog(true); + }; + + // 确认下载 + const handleConfirmDownload = async () => { + if (!selectedItem || !customName.trim()) { + return; + } + + setDownloadingId(selectedItem.guid); + setShowNameDialog(false); + + try { + const response = await fetch('/api/acg/download', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url: selectedItem.torrentUrl, + name: customName.trim(), + }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || '添加下载任务失败'); + } + + setToast({ + message: data.message || '已添加到离线下载队列', + type: 'success', + onClose: () => setToast(null), + }); + } catch (err: any) { + setToast({ + message: err.message || '添加下载任务失败', + type: 'error', + onClose: () => setToast(null), + }); + } finally { + setDownloadingId(null); + setSelectedItem(null); + setCustomName(''); + } + }; + + if (loading && !results) { + return ( +
+
+ +

+ 正在搜索动漫资源... +

+
+
+ ); + } + + if (error) { + return ( +
+
+ +

{error}

+
+
+ ); + } + + if (!results || results.total === 0) { + return ( +
+
+ +

+ 未找到相关资源 +

+
+
+ ); + } + + return ( +
+ {/* 搜索结果统计 */} +
+
+ 第 {currentPage} 页, + 共 {results.total} 个结果 +
+ + {/* 分页按钮 */} +
+ + +
+
+ + {/* 结果列表 */} +
+ {results.items.map((item) => ( +
+ {/* 标题 */} +
+ {item.title} +
+ + {/* 发布时间 */} +
+ {new Date(item.pubDate).toLocaleString('zh-CN')} +
+ + {/* 图片预览 */} + {item.images && item.images.length > 0 && ( +
+ {item.images.slice(0, 3).map((img, imgIndex) => ( + + ))} +
+ )} + + {/* 操作按钮 */} +
+ + + + 详情 + +
+
+ ))} +
+ + {/* 命名弹窗 */} + {showNameDialog && ( +
+
+

+ 设置资源名称 +

+ setCustomName(e.target.value)} + placeholder='请输入资源名称' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500' + autoFocus + /> +
+ + +
+
+
+ )} + + {/* Toast 提示 */} + {toast && } +
+ ); +} diff --git a/src/components/PansouSearch.tsx b/src/components/PansouSearch.tsx index db0b702..47c7d5a 100644 --- a/src/components/PansouSearch.tsx +++ b/src/components/PansouSearch.tsx @@ -58,8 +58,8 @@ export default function PansouSearch({ const [selectedType, setSelectedType] = useState('all'); // 'all' 表示显示全部 useEffect(() => { - // 只在 triggerSearch 变化时执行搜索,不响应 keyword 变化 - if (!triggerSearch) { + // triggerSearch 变化时触发搜索(无论是 true 还是 false) + if (triggerSearch === undefined) { return; } diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts index 4484435..070c40d 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -106,6 +106,7 @@ export interface AdminConfig { Username: string; // 账号(用于登录获取Token) Password: string; // 密码(用于登录获取Token) RootPath: string; // 根目录路径,默认 "/" + OfflineDownloadPath: string; // 离线下载目录,默认 "/" LastRefreshTime?: number; // 上次刷新时间戳 ResourceCount?: number; // 资源数量 ScanInterval?: number; // 定时扫描间隔(分钟),0表示关闭,最低60分钟