378 lines
12 KiB
TypeScript
378 lines
12 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
|
||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||
import { getAvailableApiSites, getConfig } from '@/lib/config';
|
||
import { searchFromApi } from '@/lib/downstream';
|
||
import { yellowWords } from '@/lib/yellow';
|
||
|
||
export const runtime = 'nodejs';
|
||
|
||
export async function GET(request: NextRequest) {
|
||
const authInfo = getAuthInfoFromCookie(request);
|
||
if (!authInfo || !authInfo.username) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||
}
|
||
|
||
const { searchParams } = new URL(request.url);
|
||
const query = searchParams.get('q');
|
||
|
||
if (!query) {
|
||
return new Response(
|
||
JSON.stringify({ error: '搜索关键词不能为空' }),
|
||
{
|
||
status: 400,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
}
|
||
);
|
||
}
|
||
|
||
const config = await getConfig();
|
||
const apiSites = await getAvailableApiSites(authInfo.username);
|
||
|
||
// 检查是否配置了 OpenList
|
||
const hasOpenList = !!(
|
||
config.OpenListConfig?.Enabled &&
|
||
config.OpenListConfig?.URL &&
|
||
config.OpenListConfig?.Username &&
|
||
config.OpenListConfig?.Password
|
||
);
|
||
|
||
// 检查是否配置了 Emby
|
||
const hasEmby = !!(
|
||
config.EmbyConfig?.Enabled &&
|
||
config.EmbyConfig?.ServerURL &&
|
||
config.EmbyConfig?.UserId
|
||
);
|
||
|
||
// 共享状态
|
||
let streamClosed = false;
|
||
|
||
// 创建可读流
|
||
const stream = new ReadableStream({
|
||
async start(controller) {
|
||
const encoder = new TextEncoder();
|
||
|
||
// 辅助函数:安全地向控制器写入数据
|
||
const safeEnqueue = (data: Uint8Array) => {
|
||
try {
|
||
if (streamClosed || (!controller.desiredSize && controller.desiredSize !== 0)) {
|
||
// 流已标记为关闭或控制器已关闭
|
||
return false;
|
||
}
|
||
controller.enqueue(data);
|
||
return true;
|
||
} catch (error) {
|
||
// 控制器已关闭或出现其他错误
|
||
console.warn('Failed to enqueue data:', error);
|
||
streamClosed = true;
|
||
return false;
|
||
}
|
||
};
|
||
|
||
// 发送开始事件
|
||
const startEvent = `data: ${JSON.stringify({
|
||
type: 'start',
|
||
query,
|
||
totalSources: apiSites.length + (hasOpenList ? 1 : 0) + (hasEmby ? 1 : 0),
|
||
timestamp: Date.now()
|
||
})}\n\n`;
|
||
|
||
if (!safeEnqueue(encoder.encode(startEvent))) {
|
||
return; // 连接已关闭,提前退出
|
||
}
|
||
|
||
// 记录已完成的源数量
|
||
let completedSources = 0;
|
||
const allResults: any[] = [];
|
||
|
||
// 搜索 Emby(如果配置了)
|
||
if (hasEmby) {
|
||
try {
|
||
const { EmbyClient } = await import('@/lib/emby.client');
|
||
const client = new EmbyClient(config.EmbyConfig!);
|
||
|
||
const searchResult = await client.getItems({
|
||
searchTerm: query,
|
||
IncludeItemTypes: 'Movie,Series',
|
||
Recursive: true,
|
||
Fields: 'Overview,ProductionYear',
|
||
Limit: 50,
|
||
});
|
||
|
||
const embyResults = searchResult.Items.map((item) => ({
|
||
id: item.Id,
|
||
source: 'emby',
|
||
source_name: 'Emby',
|
||
title: item.Name,
|
||
poster: client.getImageUrl(item.Id, 'Primary'),
|
||
episodes: [],
|
||
episodes_titles: [],
|
||
year: item.ProductionYear?.toString() || '',
|
||
desc: item.Overview || '',
|
||
type_name: item.Type === 'Movie' ? '电影' : '电视剧',
|
||
douban_id: 0,
|
||
}));
|
||
|
||
completedSources++;
|
||
|
||
if (!streamClosed) {
|
||
const sourceEvent = `data: ${JSON.stringify({
|
||
type: 'source_result',
|
||
source: 'emby',
|
||
sourceName: 'Emby',
|
||
results: embyResults,
|
||
timestamp: Date.now()
|
||
})}\n\n`;
|
||
|
||
if (!safeEnqueue(encoder.encode(sourceEvent))) {
|
||
streamClosed = true;
|
||
return;
|
||
}
|
||
|
||
if (embyResults.length > 0) {
|
||
allResults.push(...embyResults);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('[Search WS] 搜索 Emby 失败:', error);
|
||
completedSources++;
|
||
|
||
if (!streamClosed) {
|
||
const errorEvent = `data: ${JSON.stringify({
|
||
type: 'source_error',
|
||
source: 'emby',
|
||
sourceName: 'Emby',
|
||
error: error instanceof Error ? error.message : '搜索失败',
|
||
timestamp: Date.now()
|
||
})}\n\n`;
|
||
|
||
if (!safeEnqueue(encoder.encode(errorEvent))) {
|
||
streamClosed = true;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 搜索 OpenList(如果配置了)
|
||
if (hasOpenList) {
|
||
try {
|
||
const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache');
|
||
const { getTMDBImageUrl } = await import('@/lib/tmdb.search');
|
||
const { db } = await import('@/lib/db');
|
||
|
||
const rootPath = config.OpenListConfig!.RootPath || '/';
|
||
let metaInfo = getCachedMetaInfo(rootPath);
|
||
|
||
// 如果没有缓存,尝试从数据库读取
|
||
if (!metaInfo) {
|
||
try {
|
||
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||
if (metainfoJson) {
|
||
metaInfo = JSON.parse(metainfoJson);
|
||
if (metaInfo) {
|
||
setCachedMetaInfo(rootPath, metaInfo);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('[Search WS] 从数据库读取 metainfo 失败:', error);
|
||
}
|
||
}
|
||
|
||
if (metaInfo && metaInfo.folders) {
|
||
const openlistResults = Object.entries(metaInfo.folders)
|
||
.filter(([key, info]: [string, any]) => {
|
||
const matchFolder = info.folderName.toLowerCase().includes(query.toLowerCase());
|
||
const matchTitle = info.title.toLowerCase().includes(query.toLowerCase());
|
||
return matchFolder || matchTitle;
|
||
})
|
||
.map(([key, info]: [string, any]) => ({
|
||
id: key,
|
||
source: 'openlist',
|
||
source_name: '私人影库',
|
||
title: info.title,
|
||
poster: getTMDBImageUrl(info.poster_path),
|
||
episodes: [],
|
||
episodes_titles: [],
|
||
year: info.release_date.split('-')[0] || '',
|
||
desc: info.overview,
|
||
type_name: info.media_type === 'movie' ? '电影' : '电视剧',
|
||
douban_id: 0,
|
||
}));
|
||
|
||
completedSources++;
|
||
|
||
if (!streamClosed) {
|
||
const sourceEvent = `data: ${JSON.stringify({
|
||
type: 'source_result',
|
||
source: 'openlist',
|
||
sourceName: '私人影库',
|
||
results: openlistResults,
|
||
timestamp: Date.now()
|
||
})}\n\n`;
|
||
|
||
if (!safeEnqueue(encoder.encode(sourceEvent))) {
|
||
streamClosed = true;
|
||
return;
|
||
}
|
||
|
||
if (openlistResults.length > 0) {
|
||
allResults.push(...openlistResults);
|
||
}
|
||
}
|
||
} else {
|
||
completedSources++;
|
||
|
||
if (!streamClosed) {
|
||
const sourceEvent = `data: ${JSON.stringify({
|
||
type: 'source_result',
|
||
source: 'openlist',
|
||
sourceName: '私人影库',
|
||
results: [],
|
||
timestamp: Date.now()
|
||
})}\n\n`;
|
||
|
||
if (!safeEnqueue(encoder.encode(sourceEvent))) {
|
||
streamClosed = true;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('[Search WS] 搜索 OpenList 失败:', error);
|
||
completedSources++;
|
||
|
||
if (!streamClosed) {
|
||
const errorEvent = `data: ${JSON.stringify({
|
||
type: 'source_error',
|
||
source: 'openlist',
|
||
sourceName: '私人影库',
|
||
error: error instanceof Error ? error.message : '搜索失败',
|
||
timestamp: Date.now()
|
||
})}\n\n`;
|
||
|
||
if (!safeEnqueue(encoder.encode(errorEvent))) {
|
||
streamClosed = true;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 为每个源创建搜索 Promise
|
||
const searchPromises = apiSites.map(async (site) => {
|
||
try {
|
||
// 添加超时控制
|
||
const searchPromise = Promise.race([
|
||
searchFromApi(site, query),
|
||
new Promise((_, reject) =>
|
||
setTimeout(() => reject(new Error(`${site.name} timeout`)), 20000)
|
||
),
|
||
]);
|
||
|
||
const results = await searchPromise as any[];
|
||
|
||
// 过滤黄色内容
|
||
let filteredResults = results;
|
||
if (!config.SiteConfig.DisableYellowFilter) {
|
||
filteredResults = results.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: site.key,
|
||
sourceName: site.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(`搜索失败 ${site.name}:`, error);
|
||
|
||
// 发送源错误事件
|
||
completedSources++;
|
||
|
||
if (!streamClosed) {
|
||
const errorEvent = `data: ${JSON.stringify({
|
||
type: 'source_error',
|
||
source: site.key,
|
||
sourceName: site.name,
|
||
error: error instanceof Error ? error.message : '搜索失败',
|
||
timestamp: Date.now()
|
||
})}\n\n`;
|
||
|
||
if (!safeEnqueue(encoder.encode(errorEvent))) {
|
||
streamClosed = true;
|
||
return; // 连接已关闭,停止处理
|
||
}
|
||
}
|
||
}
|
||
|
||
// 检查是否所有源都已完成
|
||
if (completedSources === apiSites.length + (hasOpenList ? 1 : 0) + (hasEmby ? 1 : 0)) {
|
||
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);
|
||
},
|
||
|
||
cancel() {
|
||
// 客户端断开连接时,标记流已关闭
|
||
streamClosed = true;
|
||
console.log('Client disconnected, cancelling search stream');
|
||
},
|
||
});
|
||
|
||
// 返回流式响应
|
||
return new Response(stream, {
|
||
headers: {
|
||
'Content-Type': 'text/event-stream',
|
||
'Cache-Control': 'no-cache',
|
||
'Connection': 'keep-alive',
|
||
'Access-Control-Allow-Origin': '*',
|
||
'Access-Control-Allow-Methods': 'GET',
|
||
'Access-Control-Allow-Headers': 'Content-Type',
|
||
},
|
||
});
|
||
}
|