diff --git a/src/app/advanced-recommendation/page.tsx b/src/app/advanced-recommendation/page.tsx new file mode 100644 index 0000000..08bba6c --- /dev/null +++ b/src/app/advanced-recommendation/page.tsx @@ -0,0 +1,229 @@ +'use client'; + +import { Blend, Loader2 } from 'lucide-react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; + +import { SearchResult } from '@/lib/types'; + +import CapsuleSwitch from '@/components/CapsuleSwitch'; +import PageLayout from '@/components/PageLayout'; +import VideoCard from '@/components/VideoCard'; + +interface ScriptSourceOption { + key: string; + name: string; + description?: string; +} + +export default function AdvancedRecommendationPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const loadMoreRef = useRef(null); + const initialUrlSourceRef = useRef(searchParams.get('source') || ''); + + const [sources, setSources] = useState([]); + const [selectedSource, setSelectedSource] = useState(''); + const [videos, setVideos] = useState([]); + const [isLoadingSources, setIsLoadingSources] = useState(true); + const [isLoadingVideos, setIsLoadingVideos] = useState(false); + const [error, setError] = useState(''); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + const initializedRef = useRef(false); + const hasSyncedUrlRef = useRef(false); + + useEffect(() => { + const fetchSources = async () => { + setIsLoadingSources(true); + try { + const response = await fetch('/api/advanced-recommendation/sources'); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || '获取脚本源失败'); + } + + const nextSources: ScriptSourceOption[] = Array.isArray(data.sources) + ? data.sources + : []; + setSources(nextSources); + + const initialSource = + nextSources.find((item) => item.key === initialUrlSourceRef.current) + ?.key || + nextSources[0]?.key || + ''; + + setSelectedSource(initialSource); + } catch (err) { + setError(err instanceof Error ? err.message : '获取脚本源失败'); + } finally { + setIsLoadingSources(false); + initializedRef.current = true; + } + }; + + fetchSources(); + }, []); + + useEffect(() => { + if (!initializedRef.current || !selectedSource) return; + if (!hasSyncedUrlRef.current) { + hasSyncedUrlRef.current = true; + if (initialUrlSourceRef.current === selectedSource) return; + } + + const params = new URLSearchParams(); + params.set('source', selectedSource); + router.replace(`/advanced-recommendation?${params.toString()}`, { + scroll: false, + }); + }, [selectedSource, router]); + + useEffect(() => { + if (!selectedSource) return; + + setVideos([]); + setPage(1); + setHasMore(true); + setError(''); + }, [selectedSource]); + + useEffect(() => { + if (!selectedSource) return; + + const fetchVideos = async () => { + setIsLoadingVideos(true); + try { + const response = await fetch( + `/api/advanced-recommendation/videos?source=${encodeURIComponent(selectedSource)}&page=${page}` + ); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || '获取推荐失败'); + } + + const nextResults = Array.isArray(data.results) ? data.results : []; + setVideos((prev) => (page === 1 ? nextResults : [...prev, ...nextResults])); + setHasMore(Number(data.page || page) < Number(data.pageCount || 1)); + } catch (err) { + setError(err instanceof Error ? err.message : '获取推荐失败'); + } finally { + setIsLoadingVideos(false); + } + }; + + fetchVideos(); + }, [selectedSource, page]); + + useEffect(() => { + if (!loadMoreRef.current || !hasMore || isLoadingVideos) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + setPage((prev) => prev + 1); + } + }, + { threshold: 0.1 } + ); + + observer.observe(loadMoreRef.current); + return () => observer.disconnect(); + }, [hasMore, isLoadingVideos]); + + return ( + +
+
+

+ + 高级推荐 +

+

+ 浏览视频源脚本提供的推荐内容 +

+
+ +
+
+ + {isLoadingSources ? ( +
+ + + 加载脚本源中... + +
+ ) : sources.length === 0 ? ( +
+ 暂无可用的视频源脚本 +
+ ) : ( +
+ ({ + label: item.name, + value: item.key, + }))} + active={selectedSource} + onChange={setSelectedSource} + /> +
+ )} +
+ + {!!error && ( +
+ {error} +
+ )} + + {!isLoadingSources && sources.length > 0 && ( + <> + {videos.length > 0 ? ( +
+ {videos.map((video, index) => ( + + ))} +
+ ) : !isLoadingVideos && !error ? ( +
+ 当前脚本暂无推荐内容 +
+ ) : null} + + {isLoadingVideos && ( +
+ +
+ )} + +
+ + )} +
+
+ + ); +} diff --git a/src/app/api/advanced-recommendation/sources/route.ts b/src/app/api/advanced-recommendation/sources/route.ts new file mode 100644 index 0000000..3d0db61 --- /dev/null +++ b/src/app/api/advanced-recommendation/sources/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { listEnabledSourceScripts } from '@/lib/source-script'; + +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 }); + } + + try { + const scripts = await listEnabledSourceScripts(); + + return NextResponse.json({ + sources: scripts.map((item) => ({ + key: item.key, + name: item.name, + description: item.description, + })), + }); + } catch (error) { + return NextResponse.json( + { error: '获取高级推荐脚本失败' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/advanced-recommendation/videos/route.ts b/src/app/api/advanced-recommendation/videos/route.ts new file mode 100644 index 0000000..004cfc7 --- /dev/null +++ b/src/app/api/advanced-recommendation/videos/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { + executeSavedSourceScript, + normalizeScriptRecommendResults, + normalizeScriptSources, +} from '@/lib/source-script'; + +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 sourceKey = searchParams.get('source'); + const page = Number(searchParams.get('page') || '1'); + + if (!sourceKey) { + return NextResponse.json({ error: '缺少参数: source' }, { status: 400 }); + } + + try { + let sources = [{ id: 'default', name: '默认源' }]; + + try { + const sourcesExecution = await executeSavedSourceScript({ + key: sourceKey, + hook: 'getSources', + payload: {}, + }); + sources = normalizeScriptSources(sourcesExecution.result); + } catch { + // 允许脚本未实现 getSources,继续使用默认源 + } + + const execution = await executeSavedSourceScript({ + key: sourceKey, + hook: 'recommend', + payload: { page }, + }); + + const results = normalizeScriptRecommendResults({ + scriptKey: sourceKey, + scriptName: execution.meta?.name || sourceKey, + result: execution.result, + sources, + defaultSourceId: sources[0]?.id || 'default', + }); + + return NextResponse.json({ + results, + page: Number(execution.result?.page || page), + pageCount: Number(execution.result?.pageCount || 1), + total: Number(execution.result?.total || results.length), + }); + } catch (error) { + return NextResponse.json( + { error: (error as Error).message || '获取高级推荐失败' }, + { status: 500 } + ); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e0eea95..cf54887 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,6 +6,7 @@ import { Inter } from 'next/font/google'; import './globals.css'; import { getConfig } from '@/lib/config'; +import { listEnabledSourceScripts } from '@/lib/source-script'; import { DanmakuCacheCleanup } from '../components/DanmakuCacheCleanup'; import { DownloadBubble } from '../components/DownloadBubble'; @@ -91,6 +92,7 @@ export default async function RootLayout({ let webLiveEnabled = false; let customAdFilterVersion = 0; let tuneHubEnabled = false; + let advancedRecommendationEnabled = false; let customCategories = [] as { name: string; type: 'movie' | 'tv'; @@ -145,6 +147,9 @@ export default async function RootLayout({ customAdFilterVersion = config.SiteConfig?.CustomAdFilterVersion || 0; // TuneHub音乐功能配置 tuneHubEnabled = config.MusicConfig?.TuneHubEnabled || false; + // 高级推荐功能配置:存在已启用视频源脚本时显示 + advancedRecommendationEnabled = + (await listEnabledSourceScripts()).length > 0; // 检查是否启用了 OpenList 功能 openListEnabled = !!( config.OpenListConfig?.Enabled && @@ -207,6 +212,7 @@ export default async function RootLayout({ AI_DEFAULT_MESSAGE_WITH_VIDEO: aiDefaultMessageWithVideo, ENABLE_MOVIE_REQUEST: enableMovieRequest, WEB_LIVE_ENABLED: webLiveEnabled, + ADVANCED_RECOMMENDATION_ENABLED: advancedRecommendationEnabled, CUSTOM_AD_FILTER_VERSION: customAdFilterVersion, TUNEHUB_ENABLED: tuneHubEnabled, FESTIVE_EFFECT_ENABLED: diff --git a/src/components/MobileBottomNav.tsx b/src/components/MobileBottomNav.tsx index 4af17e9..1154e95 100644 --- a/src/components/MobileBottomNav.tsx +++ b/src/components/MobileBottomNav.tsx @@ -2,7 +2,7 @@ 'use client'; -import { Cat, Clover, Film, FolderOpen, Globe, Home, Star, Tv, TvMinimalPlay, Users } from 'lucide-react'; +import { Blend, Cat, Clover, Container, Film, Globe, Home, Star, Tv, TvMinimalPlay, Users } from 'lucide-react'; import Link from 'next/link'; import { usePathname, useSearchParams } from 'next/navigation'; import { useEffect, useState } from 'react'; @@ -107,12 +107,20 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => { // 如果配置了 OpenList 或 Emby,添加私人影库入口 if (runtimeConfig?.PRIVATE_LIBRARY_ENABLED) { items.push({ - icon: FolderOpen, + icon: Container, label: '私人影库', href: '/private-library', }); } + if (runtimeConfig?.ADVANCED_RECOMMENDATION_ENABLED) { + items.push({ + icon: Blend, + label: '高级推荐', + href: '/advanced-recommendation', + }); + } + // 如果启用观影室,添加观影室入口 if (watchRoomContext?.isEnabled) { items.push({ diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 6be1c20..ae0f270 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -2,7 +2,7 @@ 'use client'; -import { Cat, Clover, Film, FolderOpen, Globe, Home, Menu, Search, Star, Tv, TvMinimalPlay, Users } from 'lucide-react'; +import { Blend, Cat, Clover, Container, Film, Globe, Home, Menu, Search, Star, Tv, TvMinimalPlay, Users } from 'lucide-react'; import Link from 'next/link'; import { usePathname, useSearchParams } from 'next/navigation'; import { @@ -198,12 +198,20 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => { // 如果配置了 OpenList 或 Emby,添加私人影库入口 if (runtimeConfig?.PRIVATE_LIBRARY_ENABLED) { items.push({ - icon: FolderOpen, + icon: Container, label: '私人影库', href: '/private-library', }); } + if (runtimeConfig?.ADVANCED_RECOMMENDATION_ENABLED) { + items.push({ + icon: Blend, + label: '高级推荐', + href: '/advanced-recommendation', + }); + } + // 如果启用观影室,添加观影室入口 if (watchRoomContext?.isEnabled) { items.push({ diff --git a/src/lib/source-script.ts b/src/lib/source-script.ts index e0b7ead..ab72966 100644 --- a/src/lib/source-script.ts +++ b/src/lib/source-script.ts @@ -777,6 +777,52 @@ export function normalizeScriptSearchResults(input: { }); } +export function normalizeScriptRecommendResults(input: { + scriptKey: string; + scriptName: string; + result: any; + sources?: ScriptSourceDescriptor[]; + defaultSourceId?: string; +}) { + const list = Array.isArray(input.result?.list) ? input.result.list : []; + const sourceMap = new Map( + (input.sources || []).map((item) => [String(item.id), String(item.name)]) + ); + const fallbackSourceId = input.defaultSourceId || 'default'; + + return list.map((item: any) => { + const sourceId = String( + item?.sourceId || item?.source_id || item?.source || fallbackSourceId + ); + const sourceName = String( + item?.sourceName || + item?.source_name || + sourceMap.get(sourceId) || + sourceId + ); + + return { + id: String(item?.id || ''), + title: String(item?.title || ''), + poster: item?.poster || '', + episodes: Array.isArray(item?.episodes) ? item.episodes : [], + episodes_titles: Array.isArray(item?.episodes_titles) + ? item.episodes_titles + : [], + source: buildScriptSourceValue(input.scriptKey, sourceId), + source_name: `${input.scriptName} / ${sourceName}`, + year: item?.year || '', + desc: item?.desc || '', + type_name: item?.type_name || '', + douban_id: item?.douban_id || 0, + vod_remarks: item?.vod_remarks, + vod_total: item?.vod_total, + tmdb_id: item?.tmdb_id, + rating: item?.rating, + }; + }); +} + export function normalizeScriptDetailResult(input: { source: string; scriptKey: string;