From 00f0995762fad085540479064482c0a2458e3bfb Mon Sep 17 00:00:00 2001 From: mtvpls <247332661+mtvpls@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:12:53 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=8F=AA=E6=90=9C=E7=A7=81?= =?UTF-8?q?=E4=BA=BA=E5=BD=B1=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/search/route.ts | 7 +- src/app/api/search/ws/route.ts | 46 ++++- src/app/search/page.tsx | 329 +++++++++++++++++++++++++-------- 3 files changed, 299 insertions(+), 83 deletions(-) diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 44f5d7b..93a018f 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -26,6 +26,7 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const query = searchParams.get('q'); const includeSpecialSources = searchParams.get('special') === '1'; + const privateOnly = searchParams.get('privateOnly') === '1'; if (!query) { const cacheTime = await getCacheTime(); @@ -43,7 +44,9 @@ export async function GET(request: NextRequest) { } const config = await getConfig(); - const apiSites = await getAvailableApiSites(authInfo.username, includeSpecialSources); + const apiSites = privateOnly + ? [] + : await getAvailableApiSites(authInfo.username, includeSpecialSources); const [canAccessOpenList, canAccessEmby] = await Promise.all([ hasFeaturePermission(authInfo.username, 'private_library'), hasFeaturePermission(authInfo.username, 'emby'), @@ -191,7 +194,7 @@ export async function GET(request: NextRequest) { }) ); - const scriptSummaries = await listEnabledSourceScripts(); + const scriptSummaries = privateOnly ? [] : await listEnabledSourceScripts(); const scriptPromises = scriptSummaries.map((script) => Promise.race([ (async () => { diff --git a/src/app/api/search/ws/route.ts b/src/app/api/search/ws/route.ts index 32f83ce..d4a70c4 100644 --- a/src/app/api/search/ws/route.ts +++ b/src/app/api/search/ws/route.ts @@ -26,6 +26,7 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const query = searchParams.get('q'); const includeSpecialSources = searchParams.get('special') === '1'; + const privateOnly = searchParams.get('privateOnly') === '1'; if (!query) { return new Response( @@ -40,7 +41,9 @@ export async function GET(request: NextRequest) { } const config = await getConfig(); - const apiSites = await getAvailableApiSites(authInfo.username, includeSpecialSources); + const apiSites = privateOnly + ? [] + : await getAvailableApiSites(authInfo.username, includeSpecialSources); const [canAccessOpenList, canAccessEmby] = await Promise.all([ hasFeaturePermission(authInfo.username, 'private_library'), hasFeaturePermission(authInfo.username, 'emby'), @@ -75,7 +78,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(); + const enabledScripts = privateOnly ? [] : await listEnabledSourceScripts(); // 共享状态 let streamClosed = false; @@ -114,11 +117,13 @@ export async function GET(request: NextRequest) { } } + const totalSourceCount = sortedApiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount + enabledScripts.length; + // 发送开始事件 const startEvent = `data: ${JSON.stringify({ type: 'start', query, - totalSources: sortedApiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount + enabledScripts.length, + totalSources: totalSourceCount, timestamp: Date.now() })}\n\n`; @@ -130,6 +135,30 @@ export async function GET(request: NextRequest) { let completedSources = 0; const allResults: any[] = []; + const maybeComplete = () => { + if (completedSources !== totalSourceCount || streamClosed) return; + const completeEvent = `data: ${JSON.stringify({ + type: 'complete', + totalResults: allResults.length, + completedSources, + timestamp: Date.now() + })}\n\n`; + + if (safeEnqueue(encoder.encode(completeEvent))) { + streamClosed = true; + try { + controller.close(); + } catch (error) { + console.warn('Failed to close controller:', error); + } + } + }; + + if (totalSourceCount === 0) { + maybeComplete(); + return; + } + // 搜索 Emby(如果配置了)- 异步带超时,支持多源 if (hasEmby) { (async () => { @@ -192,6 +221,7 @@ export async function GET(request: NextRequest) { streamClosed = true; } } + maybeComplete(); return results; } catch (error) { @@ -211,6 +241,7 @@ export async function GET(request: NextRequest) { })}\n\n`; safeEnqueue(encoder.encode(sourceEvent)); } + maybeComplete(); return []; } }); @@ -232,6 +263,7 @@ export async function GET(request: NextRequest) { })}\n\n`; safeEnqueue(encoder.encode(sourceEvent)); } + maybeComplete(); } } })(); @@ -310,6 +342,7 @@ export async function GET(request: NextRequest) { allResults.push(...safeResults); } } + maybeComplete(); }) .catch((error) => { console.error('[Search WS] 搜索 OpenList 超时:', error); @@ -324,6 +357,7 @@ export async function GET(request: NextRequest) { })}\n\n`; safeEnqueue(encoder.encode(sourceEvent)); } + maybeComplete(); }); } @@ -402,7 +436,7 @@ export async function GET(request: NextRequest) { } // 检查是否所有源都已完成 - if (completedSources === sortedApiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount + enabledScripts.length) { + if (completedSources === totalSourceCount) { if (!streamClosed) { // 发送最终完成事件 const completeEvent = `data: ${JSON.stringify({ @@ -414,6 +448,7 @@ export async function GET(request: NextRequest) { if (safeEnqueue(encoder.encode(completeEvent))) { // 只有在成功发送完成事件后才关闭流 + streamClosed = true; try { controller.close(); } catch (error) { @@ -514,7 +549,7 @@ export async function GET(request: NextRequest) { } } - if (completedSources === sortedApiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount + enabledScripts.length) { + if (completedSources === totalSourceCount) { if (!streamClosed) { const completeEvent = `data: ${JSON.stringify({ type: 'complete', @@ -524,6 +559,7 @@ export async function GET(request: NextRequest) { })}\n\n`; if (safeEnqueue(encoder.encode(completeEvent))) { + streamClosed = true; try { controller.close(); } catch (error) { diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 73d88c6..53da44d 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -127,10 +127,20 @@ function SearchPageClient() { const [isFromCache, setIsFromCache] = useState(false); // 精确搜索开关 const [exactSearch, setExactSearch] = useState(true); + const [advancedOpen, setAdvancedOpen] = useState(false); + const [privateLibraryOnly, setPrivateLibraryOnly] = useState(false); + const [privateLibraryOnlyReady, setPrivateLibraryOnlyReady] = useState(false); + const privateLibraryOnlyLoadedRef = useRef(false); + const advancedButtonRefs = useRef([]); + const advancedDropdownRefs = useRef([]); // 生成缓存键 const getCacheKey = (query: string) => { - const suffix = isSpecialSourcesEnabledOnDevice() ? '_special' : ''; + const suffixParts = [ + isSpecialSourcesEnabledOnDevice() ? 'special' : '', + privateLibraryOnly ? 'private' : '', + ].filter(Boolean); + const suffix = suffixParts.length > 0 ? `_${suffixParts.join('_')}` : ''; return `search_cache_${query.trim()}${suffix}`; }; @@ -812,6 +822,35 @@ function SearchPageClient() { } }, [resultDisplayMode]); + useEffect(() => { + if (typeof window !== 'undefined' && privateLibraryOnlyLoadedRef.current) { + localStorage.setItem('searchPrivateLibraryOnly', String(privateLibraryOnly)); + } + }, [privateLibraryOnly]); + + useEffect(() => { + if (!advancedOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + const clickedButton = advancedButtonRefs.current.some((ref) => + ref?.contains(target) + ); + const clickedDropdown = advancedDropdownRefs.current.some((ref) => + ref?.contains(target) + ); + + if (!clickedButton && !clickedDropdown) { + setAdvancedOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [advancedOpen]); + const getSearchResultUrl = (params: { title: string; year?: string; @@ -1064,6 +1103,15 @@ function SearchPageClient() { if (savedExactSearch !== null) { setExactSearch(savedExactSearch === 'true'); } + + const savedPrivateLibraryOnly = localStorage.getItem( + 'searchPrivateLibraryOnly' + ); + if (savedPrivateLibraryOnly !== null) { + setPrivateLibraryOnly(savedPrivateLibraryOnly === 'true'); + } + privateLibraryOnlyLoadedRef.current = true; + setPrivateLibraryOnlyReady(true); } // 监听搜索历史更新事件 @@ -1145,8 +1193,8 @@ function SearchPageClient() { ]); useEffect(() => { - // 等待转换器初始化完成 - if (!converterReady) { + // 等待转换器和私人影库搜索设置初始化完成 + if (!converterReady || !privateLibraryOnlyReady) { return; } @@ -1292,9 +1340,10 @@ function SearchPageClient() { if (currentFluidSearch) { // 流式搜索:打开新的流式连接 - const es = new EventSource( - appendSpecialSourceParam(`/api/search/ws?q=${encodeURIComponent(trimmed)}`) - ); + const searchUrl = `/api/search/ws?q=${encodeURIComponent(trimmed)}${ + privateLibraryOnly ? '&privateOnly=1' : '' + }`; + const es = new EventSource(appendSpecialSourceParam(searchUrl)); eventSourceRef.current = es; es.onmessage = (event) => { @@ -1399,9 +1448,10 @@ function SearchPageClient() { }; } else { // 传统搜索:使用普通接口 - fetch( - appendSpecialSourceParam(`/api/search?q=${encodeURIComponent(trimmed)}`) - ) + const searchUrl = `/api/search?q=${encodeURIComponent(trimmed)}${ + privateLibraryOnly ? '&privateOnly=1' : '' + }`; + fetch(appendSpecialSourceParam(searchUrl)) .then((response) => response.json()) .then((data) => { if (currentQueryRef.current !== trimmed) return; @@ -1434,7 +1484,13 @@ function SearchPageClient() { setShowResults(false); setShowSuggestions(false); } - }, [searchParams, forceRefresh, converterReady]); + }, [ + searchParams, + forceRefresh, + converterReady, + privateLibraryOnlyReady, + privateLibraryOnly, + ]); useEffect(() => { if (!featureFlagsReady) return; @@ -1832,6 +1888,77 @@ function SearchPageClient() { )} + + {activeTab === 'video' && !showResults && ( +
+
+ + {advancedOpen && ( +
{ + if (el) advancedDropdownRefs.current[0] = el; + }} + className='absolute right-0 z-30 mt-2 w-56 rounded-xl border border-gray-200 bg-white p-3 shadow-lg dark:border-gray-700 dark:bg-gray-900' + > + + +
+ )} +
+
+ )} + {pansouCloudFilterOpen && @@ -1884,7 +2011,11 @@ function SearchPageClient() { {/* 搜索结果或搜索历史 */}
{showResults ? ( @@ -1895,58 +2026,58 @@ function SearchPageClient() { {/* 标题 */}
-

- 搜索结果 - {isFromCache ? ( - - 缓存 - - ) : ( - <> - {totalSources > 0 && useFluidSearch && ( - - 源 {completedSources}/{totalSources} - - )} - {isLoading && useFluidSearch && ( - - - - )} - - )} -

-
- - {resultCountMeta.modeLabel}{' '} - {resultCountMeta.visibleCount.toLocaleString()}{' '} - {resultCountMeta.unit} +

+ + 搜索结果 + {isFromCache && ( + + 缓存 + + )} - {resultCountMeta.isFiltered && ( - - 筛选前 {resultCountMeta.totalCount.toLocaleString()}{' '} + + + {resultCountMeta.modeLabel}{' '} + {resultCountMeta.visibleCount.toLocaleString()}{' '} {resultCountMeta.unit} + {resultCountMeta.isFiltered && ( + + / 筛选前{' '} + {resultCountMeta.totalCount.toLocaleString()}{' '} + {resultCountMeta.unit} + + )} - )} -

+ {!isFromCache && totalSources > 0 && useFluidSearch && ( + + 源 {completedSources}/{totalSources} + {isLoading && ( + + )} + + )} + + +
+
+ {searchQuery && ( + + )}
- {searchQuery && ( - - )}
@@ -1964,24 +2095,70 @@ function SearchPageClient() { /> )}
-
-