diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index b6952df..f405e55 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -364,6 +364,7 @@ interface DataSource { disabled?: boolean; from: 'config' | 'custom'; proxyMode?: boolean; + weight?: number; } // 直播源数据类型 @@ -4500,6 +4501,45 @@ const VideoSourceConfig = ({ }); }; + const handleUpdateWeight = (key: string, weight: number) => { + // 调用API更新 + withLoading(`updateWeight_${key}`, async () => { + try { + const response = await fetch('/api/admin/source', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'update_weight', + key, + weight, + }), + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || `操作失败: ${response.status}`); + } + + await refreshConfig(); + } catch (error) { + // 失败时回滚本地状态到配置中的值 + const originalWeight = config?.SourceConfig?.find(s => s.key === key)?.weight ?? 0; + setSources((prev) => + prev.map((s) => + s.key === key ? { ...s, weight: originalWeight } : s + ) + ); + showError( + error instanceof Error ? error.message : '更新权重失败', + showAlert + ); + throw error; + } + }).catch(() => { + console.error('操作失败', 'update_weight', key, weight); + }); + }; + const handleAddSource = () => { if (!newSource.name || !newSource.key || !newSource.api) return; withLoading('addSource', async () => { @@ -4812,6 +4852,36 @@ const VideoSourceConfig = ({ /> + + { + const value = parseInt(e.target.value) || 0; + const clampedValue = Math.min(100, Math.max(0, value)); + // 只更新本地状态,不调用API + setSources((prev) => + prev.map((s) => + s.key === source.key ? { ...s, weight: clampedValue } : s + ) + ); + }} + onBlur={(e) => { + const newValue = parseInt(e.target.value) || 0; + const clampedValue = Math.min(100, Math.max(0, newValue)); + const originalWeight = config?.SourceConfig?.find(s => s.key === source.key)?.weight ?? 0; + + // 只有在值发生变化时才调用API + if (clampedValue !== originalWeight) { + handleUpdateWeight(source.key, clampedValue); + } + }} + className='w-16 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent' + title='权重范围:0-100,用于排序和优选评分' + /> + {(() => { const status = getValidationStatus(source.key); @@ -5195,6 +5265,9 @@ const VideoSourceConfig = ({ 代理模式 + + 权重 + 有效性 diff --git a/src/app/api/admin/source/route.ts b/src/app/api/admin/source/route.ts index 39a5e24..8adfdd2 100644 --- a/src/app/api/admin/source/route.ts +++ b/src/app/api/admin/source/route.ts @@ -9,7 +9,7 @@ import { db } from '@/lib/db'; export const runtime = 'nodejs'; // 支持的操作类型 -type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete' | 'toggle_proxy_mode'; +type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete' | 'toggle_proxy_mode' | 'update_weight'; interface BaseBody { action?: Action; @@ -37,7 +37,7 @@ export async function POST(request: NextRequest) { const username = authInfo.username; // 基础校验 - const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort', 'batch_disable', 'batch_enable', 'batch_delete', 'toggle_proxy_mode']; + const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort', 'batch_disable', 'batch_enable', 'batch_delete', 'toggle_proxy_mode', 'update_weight']; if (!username || !action || !ACTIONS.includes(action)) { return NextResponse.json({ error: '参数格式错误' }, { status: 400 }); } @@ -254,6 +254,20 @@ export async function POST(request: NextRequest) { entry.proxyMode = !entry.proxyMode; break; } + case 'update_weight': { + const { key, weight } = body as { key?: string; weight?: number }; + if (!key) + return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 }); + if (weight === undefined || weight === null) + return NextResponse.json({ error: '缺少 weight 参数' }, { status: 400 }); + if (typeof weight !== 'number' || weight < 0 || weight > 100) + return NextResponse.json({ error: '权重必须是 0-100 之间的数字' }, { status: 400 }); + const entry = adminConfig.SourceConfig.find((s) => s.key === key); + if (!entry) + return NextResponse.json({ error: '源不存在' }, { status: 404 }); + entry.weight = weight; + break; + } default: return NextResponse.json({ error: '未知操作' }, { status: 400 }); } diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 4438ecd..bb124ab 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -36,6 +36,12 @@ export async function GET(request: NextRequest) { const config = await getConfig(); const apiSites = await getAvailableApiSites(authInfo.username); + // 创建权重映射表 + const weightMap = new Map(); + config.SourceConfig.forEach(source => { + weightMap.set(source.key, source.weight ?? 0); + }); + // 检查是否配置了 OpenList const hasOpenList = !!( config.OpenListConfig?.Enabled && @@ -191,6 +197,14 @@ export async function GET(request: NextRequest) { return !yellowWords.some((word: string) => typeName.includes(word)); }); } + + // 按权重降序排序 + flattenedResults.sort((a, b) => { + const weightA = weightMap.get(a.source) ?? 0; + const weightB = weightMap.get(b.source) ?? 0; + return weightB - weightA; + }); + const cacheTime = await getCacheTime(); if (flattenedResults.length === 0) { diff --git a/src/app/api/search/ws/route.ts b/src/app/api/search/ws/route.ts index 22d16de..e044e81 100644 --- a/src/app/api/search/ws/route.ts +++ b/src/app/api/search/ws/route.ts @@ -33,6 +33,19 @@ export async function GET(request: NextRequest) { const config = await getConfig(); const apiSites = await getAvailableApiSites(authInfo.username); + // 创建权重映射表 + const weightMap = new Map(); + config.SourceConfig.forEach(source => { + weightMap.set(source.key, source.weight ?? 0); + }); + + // 按权重降序排序 apiSites + const sortedApiSites = [...apiSites].sort((a, b) => { + const weightA = weightMap.get(a.key) ?? 0; + const weightB = weightMap.get(b.key) ?? 0; + return weightB - weightA; + }); + // 检查是否配置了 OpenList const hasOpenList = !!( config.OpenListConfig?.Enabled && @@ -89,7 +102,7 @@ export async function GET(request: NextRequest) { const startEvent = `data: ${JSON.stringify({ type: 'start', query, - totalSources: apiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount, + totalSources: sortedApiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount, timestamp: Date.now() })}\n\n`; @@ -294,7 +307,7 @@ export async function GET(request: NextRequest) { } // 为每个源创建搜索 Promise - const searchPromises = apiSites.map(async (site) => { + const searchPromises = sortedApiSites.map(async (site) => { try { // 添加超时控制 const searchPromise = Promise.race([ @@ -363,7 +376,7 @@ export async function GET(request: NextRequest) { } // 检查是否所有源都已完成 - if (completedSources === apiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount) { + if (completedSources === sortedApiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount) { if (!streamClosed) { // 发送最终完成事件 const completeEvent = `data: ${JSON.stringify({ diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 36cb4e7..b860ee2 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -1322,6 +1322,22 @@ 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<{ @@ -1388,9 +1404,15 @@ function PlayPageClient() { setPrecomputedVideoInfo(newVideoInfoMap); + // 如果所有测速都失败,仍然按权重排序返回 if (successfulResults.length === 0) { - console.warn('所有播放源测速都失败,使用第一个播放源'); - return sources[0]; + console.warn('所有播放源测速都失败,按权重排序'); + const sortedByWeight = [...sources].sort((a, b) => { + const weightA = weightMap.get(a.source) ?? 0; + const weightB = weightMap.get(b.source) ?? 0; + return weightB - weightA; + }); + return sortedByWeight[0]; } // 找出所有有效速度的最大值,用于线性映射 @@ -1425,7 +1447,8 @@ function PlayPageClient() { result.testResult, maxSpeed, minPing, - maxPing + maxPing, + weightMap.get(result.source.source) ?? 0 ), })); @@ -1455,7 +1478,8 @@ function PlayPageClient() { }, maxSpeed: number, minPing: number, - maxPing: number + maxPing: number, + weight: number = 0 ): number => { let score = 0; @@ -1513,6 +1537,9 @@ function PlayPageClient() { })(); score += pingScore * 0.2; + // 权重加分 - 直接加到总分上(0-100分) + score += weight; + return Math.round(score * 100) / 100; // 保留两位小数 }; diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts index 3eff29f..15272f6 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -78,6 +78,7 @@ export interface AdminConfig { from: 'config' | 'custom'; disabled?: boolean; proxyMode?: boolean; // 代理模式开关:启用后由服务器代理m3u8和ts分片 + weight?: number; // 权重:用于排序和优选评分,默认0,范围0-100 }[]; CustomCategories: { name?: string;