From b61865fb49a9bf264cf67a05c9ce7234bc5aaa6b Mon Sep 17 00:00:00 2001 From: mtvpls Date: Thu, 19 Mar 2026 16:10:43 +0800 Subject: [PATCH 01/33] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E6=9D=83=E9=87=8D=E8=8E=B7=E5=8F=96=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/search/route.ts | 11 +++++++++-- src/app/api/search/ws/route.ts | 7 +++++++ src/app/play/page.tsx | 22 +++------------------- src/lib/types.ts | 1 + 4 files changed, 20 insertions(+), 21 deletions(-) diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 0630ee4..0f6b89c 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -83,6 +83,7 @@ export async function GET(request: NextRequest) { id: item.Id, source: sourceValue, source_name: sourceName, + weight: weightMap.get(sourceValue) ?? 0, title: item.Name, poster: client.getImageUrl(item.Id, 'Primary', undefined, client.isProxyEnabled() ? proxyToken || undefined : undefined), episodes: [], @@ -138,6 +139,7 @@ export async function GET(request: NextRequest) { id: folderName, source: 'openlist', source_name: '私人影库', + weight: weightMap.get('openlist') ?? 0, title: info.title, poster: getTMDBImageUrl(info.poster_path), episodes: [], @@ -195,6 +197,11 @@ export async function GET(request: NextRequest) { let flattenedResults = [...openlistResults, ...embyResults, ...apiResultsFlat]; + flattenedResults = flattenedResults.map((result) => ({ + ...result, + weight: result.weight ?? (weightMap.get(result.source) ?? 0), + })); + if (!config.SiteConfig.DisableYellowFilter) { flattenedResults = flattenedResults.filter((result) => { const typeName = result.type_name || ''; @@ -204,8 +211,8 @@ export async function GET(request: NextRequest) { // 按权重降序排序 flattenedResults.sort((a, b) => { - const weightA = weightMap.get(a.source) ?? 0; - const weightB = weightMap.get(b.source) ?? 0; + const weightA = a.weight ?? 0; + const weightB = b.weight ?? 0; return weightB - weightA; }); diff --git a/src/app/api/search/ws/route.ts b/src/app/api/search/ws/route.ts index 14aed20..b3ea9f8 100644 --- a/src/app/api/search/ws/route.ts +++ b/src/app/api/search/ws/route.ts @@ -147,6 +147,7 @@ export async function GET(request: NextRequest) { id: item.Id, source: sourceValue, source_name: sourceName, + weight: weightMap.get(sourceValue) ?? 0, title: item.Name, poster: client.getImageUrl(item.Id, 'Primary', undefined, client.isProxyEnabled() ? proxyToken || undefined : undefined), episodes: [], @@ -253,6 +254,7 @@ export async function GET(request: NextRequest) { id: key, source: 'openlist', source_name: '私人影库', + weight: weightMap.get('openlist') ?? 0, title: info.title, poster: getTMDBImageUrl(info.poster_path), episodes: [], @@ -335,6 +337,11 @@ export async function GET(request: NextRequest) { }); } + filteredResults = filteredResults.map((result) => ({ + ...result, + weight: result.weight ?? (weightMap.get(result.source) ?? 0), + })); + // 发送该源的搜索结果 completedSources++; diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 68c2df7..c8999fb 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -1504,22 +1504,6 @@ function PlayPageClient() { ): Promise => { if (sources.length === 1) return sources[0]; - // 获取配置以获取权重信息 - let weightMap = new Map(); - try { - const configResponse = await fetch('/api/admin/config'); - if (configResponse.ok) { - const configData = await configResponse.json(); - if (configData.Config?.SourceConfig) { - configData.Config.SourceConfig.forEach((source: any) => { - weightMap.set(source.key, source.weight ?? 0); - }); - } - } - } catch (error) { - console.warn('获取配置失败,权重将使用默认值0:', error); - } - // 将播放源均分为两批,并发测速各批,避免一次性过多请求 const batchSize = Math.ceil(sources.length / 2); const allResults: Array<{ @@ -1602,8 +1586,8 @@ function PlayPageClient() { if (successfulResults.length === 0) { console.warn('所有播放源测速都失败,按权重排序'); const sortedByWeight = [...sources].sort((a, b) => { - const weightA = weightMap.get(a.source) ?? 0; - const weightB = weightMap.get(b.source) ?? 0; + const weightA = a.weight ?? 0; + const weightB = b.weight ?? 0; return weightB - weightA; }); return sortedByWeight[0]; @@ -1642,7 +1626,7 @@ function PlayPageClient() { maxSpeed, minPing, maxPing, - weightMap.get(result.source.source) ?? 0 + result.source.weight ?? 0 ), })); diff --git a/src/lib/types.ts b/src/lib/types.ts index e08c0a5..562bddb 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -177,6 +177,7 @@ export interface SearchResult { episodes_titles: string[]; source: string; source_name: string; + weight?: number; // 播放源权重(来自后台配置,用于排序和优选评分) class?: string; year: string; desc?: string; From b66d6076757accd6bc4a3b9bb2c3c99b414199c8 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sat, 21 Mar 2026 11:47:22 +0800 Subject: [PATCH 02/33] =?UTF-8?q?=E8=A7=86=E9=A2=91=E6=BA=90=E8=84=9A?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 616 ++++++++++++++++++++++- src/app/api/admin/source-script/route.ts | 130 +++++ src/lib/source-script.ts | 606 ++++++++++++++++++++++ 3 files changed, 1351 insertions(+), 1 deletion(-) create mode 100644 src/app/api/admin/source-script/route.ts create mode 100644 src/lib/source-script.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 07dbe5a..3346c7f 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -45,7 +45,7 @@ import { Video, } from 'lucide-react'; import { GripVertical } from 'lucide-react'; -import { memo, Suspense, useCallback, useEffect, useMemo, useState } from 'react'; +import { memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { AdminConfig, AdminConfigResult } from '@/lib/admin.types'; @@ -317,6 +317,23 @@ const useLoadingState = () => { return { loadingStates, setLoading, isLoading, withLoading }; }; +interface StandaloneSourceScript { + id: string; + key: string; + name: string; + description?: string; + enabled: boolean; + version: string; + code: string; + createdAt: number; + updatedAt: number; + history: Array<{ + version: string; + code: string; + updatedAt: number; + }>; +} + // 新增站点配置类型 interface SiteConfig { SiteName: string; @@ -5945,6 +5962,591 @@ const CategoryConfig = ({ )} {/* 通用弹窗组件 */} + + + ); +}; + +const VideoSourceScriptLab = () => { + const { alertModal, showAlert, hideAlert } = useAlertModal(); + const { isLoading, withLoading } = useLoadingState(); + const [scripts, setScripts] = useState([]); + const [loadingScripts, setLoadingScripts] = useState(true); + const [template, setTemplate] = useState(''); + const [selectedScriptId, setSelectedScriptId] = useState(null); + const [editor, setEditor] = useState<{ + id?: string; + key: string; + name: string; + description: string; + code: string; + enabled: boolean; + history: StandaloneSourceScript['history']; + version?: string; + updatedAt?: number; + }>({ + key: '', + name: '', + description: '', + code: '', + enabled: true, + history: [], + }); + const [testHook, setTestHook] = useState<'search' | 'detail' | 'resolvePlayUrl'>('search'); + const [testPayload, setTestPayload] = useState( + JSON.stringify({ keyword: '凡人修仙传', page: 1 }, null, 2) + ); + const [testOutput, setTestOutput] = useState(''); + const importInputRef = useRef(null); + + const applyEditorFromScript = (script: StandaloneSourceScript | null) => { + if (!script) { + setEditor({ + key: '', + name: '', + description: '', + code: template, + enabled: true, + history: [], + }); + setSelectedScriptId(null); + return; + } + + setEditor({ + id: script.id, + key: script.key, + name: script.name, + description: script.description || '', + code: script.code, + enabled: script.enabled, + history: script.history || [], + version: script.version, + updatedAt: script.updatedAt, + }); + setSelectedScriptId(script.id); + }; + + const loadScripts = async (preferId?: string | null) => { + setLoadingScripts(true); + try { + const response = await fetch('/api/admin/source-script', { + cache: 'no-store', + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || '加载脚本失败'); + } + + const nextScripts = (data.items || []) as StandaloneSourceScript[]; + setScripts(nextScripts); + setTemplate(data.template || ''); + + const targetId = + preferId || + selectedScriptId || + nextScripts[0]?.id || + null; + + const selected = nextScripts.find((item) => item.id === targetId) || null; + if (selected) { + applyEditorFromScript(selected); + } else { + setEditor({ + key: '', + name: '', + description: '', + code: data.template || '', + enabled: true, + history: [], + }); + setSelectedScriptId(null); + } + } catch (error) { + showError(error instanceof Error ? error.message : '加载脚本失败', showAlert); + } finally { + setLoadingScripts(false); + } + }; + + useEffect(() => { + loadScripts(); + }, []); + + const handleCreateNew = () => { + setSelectedScriptId(null); + setEditor({ + key: '', + name: '', + description: '', + code: template, + enabled: true, + history: [], + }); + setTestOutput(''); + }; + + const handleExportCurrent = () => { + if (!editor.key || !editor.name || !editor.code) { + showError('当前没有可导出的脚本', showAlert); + return; + } + + const payload = { + key: editor.key, + name: editor.name, + description: editor.description, + code: editor.code, + enabled: editor.enabled, + }; + + const blob = new Blob([JSON.stringify(payload, null, 2)], { + type: 'application/json', + }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${editor.key}.json`; + link.click(); + URL.revokeObjectURL(url); + }; + + const handleImportFile = async ( + event: React.ChangeEvent + ) => { + const file = event.target.files?.[0]; + if (!file) return; + + try { + const raw = await file.text(); + const parsed = JSON.parse(raw); + const items = Array.isArray(parsed) ? parsed : [parsed]; + + await withLoading('importSourceScript', async () => { + const response = await fetch('/api/admin/source-script', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'import', + items, + }), + }); + + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || '导入失败'); + } + + showSuccess(`已导入 ${data.items?.length || 0} 个脚本`, showAlert); + await loadScripts(data.items?.[0]?.id || null); + }); + } catch (error) { + showError(error instanceof Error ? error.message : '导入失败', showAlert); + } finally { + event.target.value = ''; + } + }; + + const handleSave = async () => { + if (!editor.key || !editor.name || !editor.code) { + showError('请填写脚本 Key、名称和代码', showAlert); + return; + } + + await withLoading('saveSourceScript', async () => { + const response = await fetch('/api/admin/source-script', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'save', + id: editor.id, + key: editor.key, + name: editor.name, + description: editor.description, + code: editor.code, + enabled: editor.enabled, + }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || '保存失败'); + } + + showSuccess('脚本已保存', showAlert); + await loadScripts(data.item?.id || editor.id || null); + }).catch((error) => { + showError(error instanceof Error ? error.message : '保存失败', showAlert); + }); + }; + + const handleDelete = async () => { + if (!editor.id) { + handleCreateNew(); + return; + } + + showAlert({ + type: 'warning', + title: '删除脚本', + message: `确定要删除脚本 "${editor.name}" 吗?`, + showConfirm: true, + onConfirm: async () => { + hideAlert(); + await withLoading('deleteSourceScript', async () => { + const response = await fetch('/api/admin/source-script', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'delete', + id: editor.id, + }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || '删除失败'); + } + showSuccess('脚本已删除', showAlert); + await loadScripts(null); + }).catch((error) => { + showError(error instanceof Error ? error.message : '删除失败', showAlert); + }); + }, + }); + }; + + const handleToggleEnabled = async (id: string) => { + await withLoading(`toggleSourceScript_${id}`, async () => { + const response = await fetch('/api/admin/source-script', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'toggle_enabled', + id, + }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || '更新失败'); + } + await loadScripts(id); + }).catch((error) => { + showError(error instanceof Error ? error.message : '更新失败', showAlert); + }); + }; + + const handleRestore = async (version: string) => { + if (!editor.id) return; + + await withLoading(`restoreSourceScript_${editor.id}`, async () => { + const response = await fetch('/api/admin/source-script', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'restore', + id: editor.id, + version, + }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || '回滚失败'); + } + showSuccess('已回滚到历史版本', showAlert); + await loadScripts(editor.id); + }).catch((error) => { + showError(error instanceof Error ? error.message : '回滚失败', showAlert); + }); + }; + + const handleTest = async () => { + let payload = {}; + try { + payload = testPayload.trim() ? JSON.parse(testPayload) : {}; + } catch { + showError('测试输入必须是合法 JSON', showAlert); + return; + } + + await withLoading('testSourceScript', async () => { + const response = await fetch('/api/admin/source-script', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'test', + key: editor.key || 'test-script', + name: editor.name || '测试脚本', + code: editor.code, + hook: testHook, + payload, + }), + }); + const data = await response.json().catch(() => ({})); + setTestOutput(JSON.stringify(data, null, 2)); + if (!response.ok) { + throw new Error(data.error || data.message || '测试失败'); + } + showSuccess('测试执行完成', showAlert); + }).catch((error) => { + showError(error instanceof Error ? error.message : '测试失败', showAlert); + }); + }; + + useEffect(() => { + setTestPayload( + testHook === 'search' + ? JSON.stringify({ keyword: '凡人修仙传', page: 1 }, null, 2) + : testHook === 'detail' + ? JSON.stringify({ id: 'demo-id' }, null, 2) + : JSON.stringify( + { + sourceId: 'demo-id', + playUrl: 'https://example.com/video.m3u8', + episodeIndex: 0, + }, + null, + 2 + ) + ); + }, [testHook]); + + return ( +
+
+
+
+

+ 脚本列表 +

+
+ + + + +
+
+ +
+ {loadingScripts ? ( +
加载中...
+ ) : scripts.length === 0 ? ( +
+ 还没有脚本,点右上角新建一个。 +
+ ) : ( + scripts.map((script) => ( + +
+ + )) + )} +
+
+ +
+
+ setEditor((prev) => ({ ...prev, name: e.target.value }))} + className='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' + /> + setEditor((prev) => ({ ...prev, key: e.target.value }))} + className='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' + /> +
+ +