脚本实装

This commit is contained in:
mtvpls
2026-03-21 14:44:53 +08:00
parent c41955952e
commit 3b5587e388
11 changed files with 960 additions and 25 deletions
+56 -10
View File
@@ -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 });
+56
View File
@@ -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 {
+69
View File
@@ -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) {
+7 -1
View File
@@ -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 });
}
+61 -3
View File
@@ -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<any[]>((_, 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,
+120 -3
View File
@@ -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() {
+56
View File
@@ -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 {