From 3b5587e3887cfa8fdff819e868352503c5771fb7 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sat, 21 Mar 2026 14:44:53 +0800 Subject: [PATCH] =?UTF-8?q?=E8=84=9A=E6=9C=AC=E5=AE=9E=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SOURCE_SCRIPT.md | 199 ++++++++++++++ src/app/admin/page.tsx | 9 +- src/app/api/admin/source-script/route.ts | 66 ++++- src/app/api/detail/route.ts | 56 ++++ src/app/api/search/one/route.ts | 69 +++++ src/app/api/search/resources/route.ts | 8 +- src/app/api/search/route.ts | 64 ++++- src/app/api/search/ws/route.ts | 123 ++++++++- src/app/api/source-detail/route.ts | 56 ++++ src/app/play/page.tsx | 6 +- src/lib/source-script.ts | 329 ++++++++++++++++++++++- 11 files changed, 960 insertions(+), 25 deletions(-) create mode 100644 SOURCE_SCRIPT.md diff --git a/SOURCE_SCRIPT.md b/SOURCE_SCRIPT.md new file mode 100644 index 0000000..2873e7b --- /dev/null +++ b/SOURCE_SCRIPT.md @@ -0,0 +1,199 @@ +# 视频源脚本编写教程 + +## 最小模板 + +```js +return { + meta: { + name: '示例脚本', + author: 'admin' + }, + + async getSources(ctx) { + return [{ id: 'default', name: '默认源' }]; + }, + + async search(ctx, { keyword, page, sourceId }) { + return { + list: [], + page, + pageCount: 1, + total: 0 + }; + }, + + async recommend(ctx, { page }) { + return { + list: [], + page: page || 1, + pageCount: 1, + total: 0 + }; + }, + + async detail(ctx, { id, sourceId }) { + return { + id, + title: '', + poster: '', + year: '', + desc: '', + playbacks: [ + { + sourceId, + sourceName: '默认源', + lineId: 'default', + lineName: '默认线路', + episodes: [], + episodes_titles: [] + } + ] + }; + }, + + async resolvePlayUrl(ctx, { playUrl, sourceId, lineId, episodeIndex }) { + return { + url: playUrl, + type: 'auto', + headers: {} + }; + } +}; +``` + +## 支持的 hook + +1. `getSources()` + 返回脚本管理的子源列表 +2. `search({ keyword, page, sourceId })` + 搜索 +3. `recommend({ page })` + 推荐 +4. `detail({ id, sourceId })` + 详情 +5. `resolvePlayUrl({ playUrl, sourceId, lineId, episodeIndex })` + 播放前解析最终地址 + +## `ctx` 里能用什么 + +1. `ctx.fetch(...)` + 发请求 +2. `ctx.request.get/getJson/getHtml/post` + 快捷请求 +3. `ctx.html.load(html)` + `cheerio` 风格解析 +4. `ctx.cache.get/set/del` + 脚本缓存 +5. `ctx.log.info/warn/error` + 输出测试日志 +6. `ctx.utils.buildUrl/joinUrl/randomUA/sleep/base64Encode/base64Decode/now` + 常用工具 +7. `ctx.config.get/require/all` + 读脚本配置 +8. `ctx.runtime` + 当前脚本信息 + +## `search` 返回格式 + +```js +{ + list: [ + { + id: '123', + title: '凡人修仙传', + poster: 'https://...', + year: '2025', + desc: '简介', + type_name: '动漫', + douban_id: 0, + vod_remarks: '更新至10集' + } + ], + page: 1, + pageCount: 1, + total: 1 +} +``` + +## `detail` 返回格式 + +```js +{ + id: '123', + title: '凡人修仙传', + poster: 'https://...', + year: '2025', + desc: '简介', + playbacks: [ + { + sourceId: 'default', + sourceName: '默认源', + lineId: 'line1', + lineName: '线路1', + episodes: [ + 'https://example.com/play/1', + 'https://example.com/play/2' + ], + episodes_titles: ['第1集', '第2集'] + } + ] +} +``` + +## `resolvePlayUrl` 返回格式 + +```js +{ + url: 'https://real-url.m3u8', + type: 'auto', + headers: {} +} +``` + +## 最简单的搜索例子 + +```js +async search(ctx, { keyword, page, sourceId }) { + const data = await ctx.request.getJson('https://example.com/api/search', { + query: { wd: keyword, pg: page } + }); + + return { + list: (data.list || []).map((item) => ({ + id: String(item.id), + title: item.title, + poster: item.pic || '', + year: item.year || '', + desc: item.desc || '' + })), + page, + pageCount: data.pagecount || 1, + total: data.total || 0 + }; +} +``` + +## 最简单的播放解析例子 + +```js +async resolvePlayUrl(ctx, { playUrl }) { + return { + url: playUrl, + type: 'auto', + headers: {} + }; +} +``` + +## 导入格式 + +```json +{ + "key": "demo", + "name": "演示脚本", + "description": "test", + "enabled": true, + "code": "return { ... }" +} +``` + diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 48c50be..2069049 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -5506,6 +5506,7 @@ const VideoSourceConfig = ({ message={alertModal.message} timer={alertModal.timer} showConfirm={alertModal.showConfirm} + onConfirm={alertModal.onConfirm} /> {/* 批量操作确认弹窗 */} @@ -6052,10 +6053,9 @@ const VideoSourceScriptLab = () => { setTemplate(data.template || ''); const targetId = - preferId || - selectedScriptId || - nextScripts[0]?.id || - null; + preferId !== undefined + ? preferId + : selectedScriptId || nextScripts[0]?.id || null; const selected = nextScripts.find((item) => item.id === targetId) || null; if (selected) { @@ -6565,6 +6565,7 @@ const VideoSourceScriptLab = () => { message={alertModal.message} timer={alertModal.timer} showConfirm={alertModal.showConfirm} + onConfirm={alertModal.onConfirm} /> ); diff --git a/src/app/api/admin/source-script/route.ts b/src/app/api/admin/source-script/route.ts index a8f3786..d1ccc2e 100644 --- a/src/app/api/admin/source-script/route.ts +++ b/src/app/api/admin/source-script/route.ts @@ -48,10 +48,17 @@ export async function GET(request: NextRequest) { } const items = await listSourceScripts(); - return NextResponse.json({ - items, - template: getDefaultSourceScriptTemplate(), - }); + return NextResponse.json( + { + items, + template: getDefaultSourceScriptTemplate(), + }, + { + headers: { + 'Cache-Control': 'no-store', + }, + } + ); } catch (error) { return NextResponse.json( { error: (error as Error).message || '获取脚本列表失败' }, @@ -80,19 +87,47 @@ export async function POST(request: NextRequest) { code: body.code, enabled: body.enabled, }); - return NextResponse.json({ ok: true, item: saved }); + return NextResponse.json( + { ok: true, item: saved }, + { + headers: { + 'Cache-Control': 'no-store', + }, + } + ); } case 'delete': { await deleteSourceScript(body.id); - return NextResponse.json({ ok: true }); + return NextResponse.json( + { ok: true }, + { + headers: { + 'Cache-Control': 'no-store', + }, + } + ); } case 'toggle_enabled': { const item = await toggleSourceScriptEnabled(body.id); - return NextResponse.json({ ok: true, item }); + return NextResponse.json( + { ok: true, item }, + { + headers: { + 'Cache-Control': 'no-store', + }, + } + ); } case 'restore': { const item = await restoreSourceScriptHistory(body.id, body.version); - return NextResponse.json({ ok: true, item }); + return NextResponse.json( + { ok: true, item }, + { + headers: { + 'Cache-Control': 'no-store', + }, + } + ); } case 'test': { const result = await testSourceScript({ @@ -108,13 +143,24 @@ export async function POST(request: NextRequest) { return NextResponse.json(result, { status: 400 }); } - return NextResponse.json(result); + return NextResponse.json(result, { + headers: { + 'Cache-Control': 'no-store', + }, + }); } case 'import': { const imported = await importSourceScripts( Array.isArray(body.items) ? body.items : [] ); - return NextResponse.json({ ok: true, items: imported }); + return NextResponse.json( + { ok: true, items: imported }, + { + headers: { + 'Cache-Control': 'no-store', + }, + } + ); } default: return NextResponse.json({ error: '未知操作' }, { status: 400 }); diff --git a/src/app/api/detail/route.ts b/src/app/api/detail/route.ts index ae3a696..916b4df 100644 --- a/src/app/api/detail/route.ts +++ b/src/app/api/detail/route.ts @@ -3,6 +3,13 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config'; import { getDetailFromApi } from '@/lib/downstream'; +import { + executeSavedSourceScript, + normalizeScriptDetailResult, + resolveScriptDetailPlaybacks, + normalizeScriptSources, + parseScriptSourceValue, +} from '@/lib/source-script'; export const runtime = 'nodejs'; @@ -20,6 +27,55 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }); } + const parsedScriptSource = parseScriptSourceValue(sourceCode); + if (parsedScriptSource) { + try { + const sourcesExecution = await executeSavedSourceScript({ + key: parsedScriptSource.scriptKey, + hook: 'getSources', + payload: {}, + }); + const sources = normalizeScriptSources(sourcesExecution.result); + const sourceInfo = + sources.find((item) => item.id === parsedScriptSource.sourceId) || { + id: parsedScriptSource.sourceId, + name: parsedScriptSource.sourceId, + }; + + const detailExecution = await executeSavedSourceScript({ + key: parsedScriptSource.scriptKey, + hook: 'detail', + payload: { + id, + sourceId: parsedScriptSource.sourceId, + }, + }); + + const resolvedDetailResult = await resolveScriptDetailPlaybacks({ + scriptKey: parsedScriptSource.scriptKey, + sourceId: parsedScriptSource.sourceId, + result: detailExecution.result, + }); + + const normalized = normalizeScriptDetailResult({ + source: sourceCode, + scriptKey: parsedScriptSource.scriptKey, + scriptName: detailExecution.meta?.name || parsedScriptSource.scriptKey, + sourceId: parsedScriptSource.sourceId, + sourceName: sourceInfo.name, + detailId: id, + result: resolvedDetailResult, + }); + + return NextResponse.json(normalized); + } catch (error) { + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ); + } + } + // 特殊处理 openlist 源 if (sourceCode === 'openlist') { try { diff --git a/src/app/api/search/one/route.ts b/src/app/api/search/one/route.ts index 61a4c30..f7deacb 100644 --- a/src/app/api/search/one/route.ts +++ b/src/app/api/search/one/route.ts @@ -3,6 +3,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config'; import { searchFromApi } from '@/lib/downstream'; +import { + executeSavedSourceScript, + listEnabledSourceScripts, + normalizeScriptSearchResults, + normalizeScriptSources, +} from '@/lib/source-script'; import { yellowWords } from '@/lib/yellow'; export const runtime = 'nodejs'; @@ -37,6 +43,69 @@ export async function GET(request: NextRequest) { const apiSites = await getAvailableApiSites(authInfo.username); try { + const enabledScripts = await listEnabledSourceScripts(); + const matchedScript = enabledScripts.find((item) => item.key === resourceId); + if (matchedScript) { + const sourcesExecution = await executeSavedSourceScript({ + key: matchedScript.key, + hook: 'getSources', + payload: {}, + }); + const sources = normalizeScriptSources(sourcesExecution.result); + const scriptResults = await Promise.all( + sources.map(async (source) => { + const execution = await executeSavedSourceScript({ + key: matchedScript.key, + hook: 'search', + payload: { + keyword: query, + page: 1, + sourceId: source.id, + }, + }); + + return normalizeScriptSearchResults({ + scriptKey: matchedScript.key, + scriptName: matchedScript.name, + sourceId: source.id, + sourceName: source.name, + result: execution.result, + }); + }) + ); + + let result = scriptResults.flat().filter((r) => r.title === query); + if (!config.SiteConfig.DisableYellowFilter) { + result = result.filter((item) => { + const typeName = item.type_name || ''; + return !yellowWords.some((word: string) => typeName.includes(word)); + }); + } + + const cacheTime = await getCacheTime(); + if (result.length === 0) { + return NextResponse.json( + { + error: '未找到结果', + result: null, + }, + { status: 404 } + ); + } + + return NextResponse.json( + { results: result }, + { + headers: { + 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, + 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`, + 'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`, + 'Netlify-Vary': 'query', + }, + } + ); + } + // 根据 resourceId 查找对应的 API 站点 const targetSite = apiSites.find((site) => site.key === resourceId); if (!targetSite) { diff --git a/src/app/api/search/resources/route.ts b/src/app/api/search/resources/route.ts index a4eb218..3947ae9 100644 --- a/src/app/api/search/resources/route.ts +++ b/src/app/api/search/resources/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAvailableApiSites } from '@/lib/config'; +import { listEnabledSourceScripts } from '@/lib/source-script'; export const runtime = 'nodejs'; @@ -11,8 +12,13 @@ export async function GET(request: NextRequest) { console.log('request', request.url); try { const apiSites = await getAvailableApiSites(); + const scriptSites = (await listEnabledSourceScripts()).map((item) => ({ + key: item.key, + name: item.name, + script: true, + })); - return NextResponse.json(apiSites); + return NextResponse.json([...apiSites, ...scriptSites]); } catch (error) { return NextResponse.json({ error: '获取资源失败' }, { status: 500 }); } diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 0f6b89c..1e10e01 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -5,8 +5,14 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config'; import { searchFromApi } from '@/lib/downstream'; -import { yellowWords } from '@/lib/yellow'; import { getProxyToken } from '@/lib/emby-token'; +import { + executeSavedSourceScript, + listEnabledSourceScripts, + normalizeScriptSearchResults, + normalizeScriptSources, +} from '@/lib/source-script'; +import { yellowWords } from '@/lib/yellow'; export const runtime = 'nodejs'; @@ -178,24 +184,76 @@ export async function GET(request: NextRequest) { }) ); + const scriptSummaries = await listEnabledSourceScripts(); + const scriptPromises = scriptSummaries.map((script) => + Promise.race([ + (async () => { + try { + const sourcesExecution = await executeSavedSourceScript({ + key: script.key, + hook: 'getSources', + payload: {}, + }); + const sources = normalizeScriptSources(sourcesExecution.result); + + const searchResults = await Promise.all( + sources.map(async (source) => { + const execution = await executeSavedSourceScript({ + key: script.key, + hook: 'search', + payload: { + keyword: query, + page: 1, + sourceId: source.id, + }, + }); + + return normalizeScriptSearchResults({ + scriptKey: script.key, + scriptName: script.name, + sourceId: source.id, + sourceName: source.name, + result: execution.result, + }); + }) + ); + + return searchResults.flat(); + } catch (error) { + console.error(`[Search] 搜索脚本 ${script.name} 失败:`, error); + return []; + } + })(), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`${script.name} timeout`)), 20000) + ), + ]).catch((error) => { + console.error(`[Search] 搜索脚本 ${script.name} 超时:`, error); + return []; + }) + ); + try { const allResults = await Promise.all([ openlistPromise, ...embyPromises, ...searchPromises, + ...scriptPromises, ]); // 分离结果:第一个是 openlist,接下来是 emby 结果,最后是 api 结果 // 添加安全检查,确保即使某个结果处理出错也不影响其他结果 const openlistResults = Array.isArray(allResults[0]) ? allResults[0] : []; const embyResultsArray = allResults.slice(1, 1 + embyPromises.length); - const apiResults = allResults.slice(1 + embyPromises.length); + const apiResults = allResults.slice(1 + embyPromises.length, 1 + embyPromises.length + searchPromises.length); + const scriptResults = allResults.slice(1 + embyPromises.length + searchPromises.length); // 合并所有 Emby 结果,添加安全检查 const embyResults = embyResultsArray.filter(Array.isArray).flat(); const apiResultsFlat = apiResults.filter(Array.isArray).flat(); + const scriptResultsFlat = scriptResults.filter(Array.isArray).flat(); - let flattenedResults = [...openlistResults, ...embyResults, ...apiResultsFlat]; + let flattenedResults = [...openlistResults, ...embyResults, ...apiResultsFlat, ...scriptResultsFlat]; flattenedResults = flattenedResults.map((result) => ({ ...result, diff --git a/src/app/api/search/ws/route.ts b/src/app/api/search/ws/route.ts index b3ea9f8..f8b750b 100644 --- a/src/app/api/search/ws/route.ts +++ b/src/app/api/search/ws/route.ts @@ -7,6 +7,12 @@ import { getAvailableApiSites, getConfig } from '@/lib/config'; import { searchFromApi } from '@/lib/downstream'; import { yellowWords } from '@/lib/yellow'; import { getProxyToken } from '@/lib/emby-token'; +import { + executeSavedSourceScript, + listEnabledSourceScripts, + normalizeScriptSearchResults, + normalizeScriptSources, +} from '@/lib/source-script'; export const runtime = 'nodejs'; @@ -61,6 +67,7 @@ export async function GET(request: NextRequest) { config.EmbyConfig.Sources.length > 0 && config.EmbyConfig.Sources.some(s => s.enabled && s.ServerURL) ); + const enabledScripts = await listEnabledSourceScripts(); // 共享状态 let streamClosed = false; @@ -103,7 +110,7 @@ export async function GET(request: NextRequest) { const startEvent = `data: ${JSON.stringify({ type: 'start', query, - totalSources: sortedApiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount, + totalSources: sortedApiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount + enabledScripts.length, timestamp: Date.now() })}\n\n`; @@ -387,7 +394,7 @@ export async function GET(request: NextRequest) { } // 检查是否所有源都已完成 - if (completedSources === sortedApiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount) { + if (completedSources === sortedApiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount + enabledScripts.length) { if (!streamClosed) { // 发送最终完成事件 const completeEvent = `data: ${JSON.stringify({ @@ -409,8 +416,118 @@ export async function GET(request: NextRequest) { } }); + const scriptPromises = enabledScripts.map(async (script) => { + try { + const sourcesExecution = await Promise.race([ + executeSavedSourceScript({ + key: script.key, + hook: 'getSources', + payload: {}, + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`${script.name} timeout`)), 20000) + ), + ]); + + const sources = normalizeScriptSources((sourcesExecution as any).result); + const sourceResults = await Promise.all( + sources.map(async (source) => { + const execution = await Promise.race([ + executeSavedSourceScript({ + key: script.key, + hook: 'search', + payload: { + keyword: query, + page: 1, + sourceId: source.id, + }, + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`${script.name}/${source.name} timeout`)), 20000) + ), + ]); + + return normalizeScriptSearchResults({ + scriptKey: script.key, + scriptName: script.name, + sourceId: source.id, + sourceName: source.name, + result: (execution as any).result, + }); + }) + ); + + let filteredResults = sourceResults.flat(); + if (!config.SiteConfig.DisableYellowFilter) { + filteredResults = filteredResults.filter((result) => { + const typeName = result.type_name || ''; + return !yellowWords.some((word: string) => typeName.includes(word)); + }); + } + + completedSources++; + + if (!streamClosed) { + const sourceEvent = `data: ${JSON.stringify({ + type: 'source_result', + source: `script:${script.key}`, + sourceName: script.name, + results: filteredResults, + timestamp: Date.now() + })}\n\n`; + + if (!safeEnqueue(encoder.encode(sourceEvent))) { + streamClosed = true; + return; + } + } + + if (filteredResults.length > 0) { + allResults.push(...filteredResults); + } + } catch (error) { + console.warn(`搜索脚本失败 ${script.name}:`, error); + + completedSources++; + + if (!streamClosed) { + const errorEvent = `data: ${JSON.stringify({ + type: 'source_error', + source: `script:${script.key}`, + sourceName: script.name, + error: error instanceof Error ? error.message : '搜索失败', + timestamp: Date.now() + })}\n\n`; + + if (!safeEnqueue(encoder.encode(errorEvent))) { + streamClosed = true; + return; + } + } + } + + if (completedSources === sortedApiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount + enabledScripts.length) { + if (!streamClosed) { + const completeEvent = `data: ${JSON.stringify({ + type: 'complete', + totalResults: allResults.length, + completedSources, + timestamp: Date.now() + })}\n\n`; + + if (safeEnqueue(encoder.encode(completeEvent))) { + try { + controller.close(); + } catch (error) { + console.warn('Failed to close controller:', error); + } + } + } + } + }); + // 等待所有搜索完成 - await Promise.allSettled(searchPromises); + await Promise.allSettled([...searchPromises, ...scriptPromises]); }, cancel() { diff --git a/src/app/api/source-detail/route.ts b/src/app/api/source-detail/route.ts index 1616174..e4a1f3f 100644 --- a/src/app/api/source-detail/route.ts +++ b/src/app/api/source-detail/route.ts @@ -6,6 +6,13 @@ import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config'; import { getDetailFromApiV2 } from '@/lib/downstream'; import { getProxyToken } from '@/lib/emby-token'; +import { + executeSavedSourceScript, + normalizeScriptDetailResult, + resolveScriptDetailPlaybacks, + normalizeScriptSources, + parseScriptSourceValue, +} from '@/lib/source-script'; export const runtime = 'nodejs'; @@ -28,6 +35,55 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }); } + const parsedScriptSource = parseScriptSourceValue(sourceCode); + if (parsedScriptSource) { + try { + const sourcesExecution = await executeSavedSourceScript({ + key: parsedScriptSource.scriptKey, + hook: 'getSources', + payload: {}, + }); + const sources = normalizeScriptSources(sourcesExecution.result); + const sourceInfo = + sources.find((item) => item.id === parsedScriptSource.sourceId) || { + id: parsedScriptSource.sourceId, + name: parsedScriptSource.sourceId, + }; + + const detailExecution = await executeSavedSourceScript({ + key: parsedScriptSource.scriptKey, + hook: 'detail', + payload: { + id, + sourceId: parsedScriptSource.sourceId, + }, + }); + + const resolvedDetailResult = await resolveScriptDetailPlaybacks({ + scriptKey: parsedScriptSource.scriptKey, + sourceId: parsedScriptSource.sourceId, + result: detailExecution.result, + }); + + const normalized = normalizeScriptDetailResult({ + source: sourceCode, + scriptKey: parsedScriptSource.scriptKey, + scriptName: detailExecution.meta?.name || parsedScriptSource.scriptKey, + sourceId: parsedScriptSource.sourceId, + sourceName: sourceInfo.name, + detailId: id, + result: resolvedDetailResult, + }); + + return NextResponse.json(normalized); + } catch (error) { + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ); + } + } + // 特殊处理 emby 源(支持多源) if (sourceCode === 'emby' || sourceCode.startsWith('emby_')) { try { diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index c8999fb..71cfd4b 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -49,8 +49,8 @@ import { getDoubanDetail } from '@/lib/douban.client'; import { getTMDBImageUrl } from '@/lib/tmdb.search'; import { DanmakuFilterConfig, EpisodeFilterConfig, SearchResult } from '@/lib/types'; import { base58Decode, getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils'; -import { useEnableComments } from '@/hooks/useEnableComments'; import { useEnableAIComments } from '@/hooks/useEnableAIComments'; +import { useEnableComments } from '@/hooks/useEnableComments'; import { usePlaySync } from '@/hooks/usePlaySync'; import AIChatPanel from '@/components/AIChatPanel'; @@ -1655,7 +1655,7 @@ function PlayPageClient() { maxSpeed: number, minPing: number, maxPing: number, - weight: number = 0 + weight = 0 ): number => { let score = 0; @@ -2162,7 +2162,7 @@ function PlayPageClient() { // 读取 m3u8 文件 const fileHandle = await fileSystemCheck.dirHandle.getFileHandle('playlist.m3u8', { create: false }); const file = await fileHandle.getFile(); - let content = await file.text(); + const content = await file.text(); // 解析 m3u8 文件,为每个 ts 文件创建 Blob URL const lines = content.split('\n'); diff --git a/src/lib/source-script.ts b/src/lib/source-script.ts index 128fd24..eb4278b 100644 --- a/src/lib/source-script.ts +++ b/src/lib/source-script.ts @@ -49,6 +49,30 @@ export interface SourceScriptTestResult { error?: string; } +export type SourceScriptHook = + | 'getSources' + | 'search' + | 'recommend' + | 'detail' + | 'resolvePlayUrl'; + +export interface PublicSourceScriptSummary { + id: string; + key: string; + name: string; + description?: string; + version: string; + updatedAt: number; +} + +export interface ScriptSourceDescriptor { + id: string; + name: string; +} + +const SCRIPT_SOURCE_PREFIX = 'script:'; +const SCRIPT_EPISODE_PREFIX = '__script_ep__'; + const DEFAULT_SCRIPT_TEMPLATE = `return { meta: { name: '示例脚本', @@ -409,6 +433,78 @@ function normalizeScript(script: any) { return script; } +async function getEnabledSourceScriptByKey(key: string) { + const registry = await loadRegistry(); + const item = registry.items.find((record) => record.key === key); + if (!item) { + throw new Error('脚本不存在'); + } + if (!item.enabled) { + throw new Error('脚本已停用'); + } + return item; +} + +async function compileSourceScript( + script: SourceScriptRecord, + configValues?: Record +) { + const factory = createScriptFactory(script.code); + const compiled = normalizeScript(factory()); + const context = await createScriptContext(script, configValues); + return { + compiled, + ...context, + }; +} + +export async function executeSavedSourceScript(input: { + key: string; + hook: SourceScriptHook; + payload?: Record; + configValues?: Record; +}): Promise { + const startedAt = Date.now(); + const script = await getEnabledSourceScriptByKey(input.key); + const { compiled, ctx, logs } = await compileSourceScript( + script, + input.configValues + ); + + const hook = compiled[input.hook]; + if (typeof hook !== 'function') { + throw new Error(`脚本未实现 ${input.hook} hook`); + } + + const result = await withTimeout( + Promise.resolve(hook(ctx, input.payload || {})), + DEFAULT_TIMEOUT_MS + ); + + return { + ok: true, + durationMs: Date.now() - startedAt, + logs, + meta: compiled.meta, + result, + }; +} + +export async function listEnabledSourceScripts(): Promise { + const registry = await loadRegistry(); + return registry.items + .filter((item) => item.enabled) + .sort((a, b) => b.updatedAt - a.updatedAt) + .map((item) => ({ + id: item.id, + key: item.key, + name: item.name, + description: item.description, + version: item.version, + updatedAt: item.updatedAt, + })); +} + export async function listSourceScripts() { const registry = await loadRegistry(); return registry.items.sort((a, b) => b.updatedAt - a.updatedAt); @@ -577,7 +673,7 @@ export async function restoreSourceScriptHistory(id: string, version: string) { export async function testSourceScript(input: { code: string; - hook: 'getSources' | 'search' | 'recommend' | 'detail' | 'resolvePlayUrl'; + hook: SourceScriptHook; payload: Record; name?: string; key?: string; @@ -631,3 +727,234 @@ export async function testSourceScript(input: { export function getDefaultSourceScriptTemplate() { return DEFAULT_SCRIPT_TEMPLATE; } + +export function buildScriptSourceValue(scriptKey: string, sourceId?: string) { + return `${SCRIPT_SOURCE_PREFIX}${scriptKey}:${sourceId || 'default'}`; +} + +export function parseScriptSourceValue(source: string) { + if (!source.startsWith(SCRIPT_SOURCE_PREFIX)) { + return null; + } + + const rest = source.slice(SCRIPT_SOURCE_PREFIX.length); + const separatorIndex = rest.indexOf(':'); + if (separatorIndex === -1) { + return { + scriptKey: rest, + sourceId: 'default', + }; + } + + return { + scriptKey: rest.slice(0, separatorIndex), + sourceId: rest.slice(separatorIndex + 1) || 'default', + }; +} + +export function encodeScriptEpisodePayload(payload: Record) { + return `${SCRIPT_EPISODE_PREFIX}${Buffer.from( + JSON.stringify(payload), + 'utf8' + ).toString('base64')}`; +} + +export function decodeScriptEpisodePayload(value: string) { + if (!value.startsWith(SCRIPT_EPISODE_PREFIX)) { + return null; + } + + try { + return JSON.parse( + Buffer.from( + value.slice(SCRIPT_EPISODE_PREFIX.length), + 'base64' + ).toString('utf8') + ) as Record; + } catch { + return null; + } +} + +export function normalizeScriptSources(result: any): ScriptSourceDescriptor[] { + if (!Array.isArray(result)) { + return [{ id: 'default', name: '默认源' }]; + } + + return result + .filter((item) => item && item.id) + .map((item) => ({ + id: String(item.id), + name: String(item.name || item.id), + })); +} + +export function normalizeScriptSearchResults(input: { + scriptKey: string; + scriptName: string; + sourceId: string; + sourceName: string; + result: any; +}) { + const list = Array.isArray(input.result?.list) ? input.result.list : []; + return list.map((item: any) => ({ + id: String(item.id), + title: String(item.title || ''), + poster: item.poster || '', + episodes: [], + episodes_titles: [], + source: buildScriptSourceValue(input.scriptKey, input.sourceId), + source_name: `${input.scriptName} / ${input.sourceName}`, + year: item.year || '', + desc: item.desc || '', + type_name: item.type_name || '', + douban_id: item.douban_id || 0, + vod_remarks: item.vod_remarks, + })); +} + +export function normalizeScriptDetailResult(input: { + source: string; + scriptKey: string; + scriptName: string; + sourceId: string; + sourceName: string; + detailId: string; + result: any; +}) { + const playbacks = Array.isArray(input.result?.playbacks) + ? input.result.playbacks + : [ + { + sourceId: input.sourceId, + sourceName: input.sourceName, + lineId: 'default', + lineName: '默认线路', + episodes: input.result?.episodes || [], + episodes_titles: input.result?.episodes_titles || [], + }, + ]; + + const flattenedEpisodes: string[] = []; + const flattenedTitles: string[] = []; + + playbacks.forEach((playback: any) => { + const playbackSourceId = String(playback.sourceId || input.sourceId); + const playbackSourceName = String(playback.sourceName || input.sourceName); + const lineId = String(playback.lineId || 'default'); + const lineName = String(playback.lineName || '默认线路'); + const titles = Array.isArray(playback.episodes_titles) + ? playback.episodes_titles + : []; + const episodes = Array.isArray(playback.episodes) ? playback.episodes : []; + + episodes.forEach((episode: any, index: number) => { + const playUrl = + typeof episode === 'string' + ? episode + : String(episode?.playUrl || episode?.url || ''); + const episodeTitle = + typeof episode === 'object' && episode?.title + ? String(episode.title) + : String(titles[index] || `第${index + 1}集`); + + flattenedEpisodes.push( + encodeScriptEpisodePayload({ + script: input.scriptKey, + sourceId: playbackSourceId, + sourceName: playbackSourceName, + lineId, + lineName, + playUrl, + episodeIndex: index, + }) + ); + flattenedTitles.push(`${playbackSourceName} / ${lineName} / ${episodeTitle}`); + }); + }); + + return { + id: input.detailId, + title: String(input.result?.title || ''), + poster: input.result?.poster || '', + episodes: flattenedEpisodes, + episodes_titles: flattenedTitles, + source: input.source, + source_name: `${input.scriptName} / ${input.sourceName}`, + class: input.result?.class, + year: input.result?.year || '', + desc: input.result?.desc || '', + type_name: input.result?.type_name || '', + douban_id: input.result?.douban_id || 0, + vod_remarks: input.result?.vod_remarks, + vod_total: input.result?.vod_total, + proxyMode: false, + }; +} + +export async function resolveScriptDetailPlaybacks(input: { + scriptKey: string; + sourceId: string; + result: any; +}) { + const playbacks = Array.isArray(input.result?.playbacks) + ? input.result.playbacks + : [ + { + sourceId: input.sourceId, + sourceName: input.sourceId, + lineId: 'default', + lineName: '默认线路', + episodes: input.result?.episodes || [], + episodes_titles: input.result?.episodes_titles || [], + }, + ]; + + const resolvedPlaybacks = await Promise.all( + playbacks.map(async (playback: any) => { + const playbackSourceId = String(playback.sourceId || input.sourceId); + const lineId = String(playback.lineId || 'default'); + const episodes = Array.isArray(playback.episodes) ? playback.episodes : []; + + const resolvedEpisodes = await Promise.all( + episodes.map(async (episode: any, index: number) => { + const playUrl = + typeof episode === 'string' + ? episode + : String(episode?.playUrl || episode?.url || ''); + + try { + const execution = await executeSavedSourceScript({ + key: input.scriptKey, + hook: 'resolvePlayUrl', + payload: { + playUrl, + sourceId: playbackSourceId, + lineId, + episodeIndex: index, + }, + }); + + return execution.result?.url || playUrl; + } catch (error) { + const message = error instanceof Error ? error.message : ''; + if (message.includes('未实现 resolvePlayUrl hook')) { + return playUrl; + } + return playUrl; + } + }) + ); + + return { + ...playback, + episodes: resolvedEpisodes, + }; + }) + ); + + return { + ...input.result, + playbacks: resolvedPlaybacks, + }; +}