高级推荐

This commit is contained in:
mtvpls
2026-03-29 11:41:03 +08:00
parent 8acc50b198
commit 7547f78472
7 changed files with 397 additions and 4 deletions

View File

@@ -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<HTMLDivElement>(null);
const initialUrlSourceRef = useRef(searchParams.get('source') || '');
const [sources, setSources] = useState<ScriptSourceOption[]>([]);
const [selectedSource, setSelectedSource] = useState('');
const [videos, setVideos] = useState<SearchResult[]>([]);
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 (
<PageLayout activePath='/advanced-recommendation'>
<div className='px-4 sm:px-10 py-4 sm:py-8 mb-10'>
<div className='mb-6'>
<h1 className='text-2xl font-bold text-gray-800 dark:text-gray-200 flex items-center gap-2'>
<Blend className='w-6 h-6 text-green-500' />
</h1>
<p className='text-sm text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<div className='max-w-6xl mx-auto space-y-6'>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3'>
</label>
{isLoadingSources ? (
<div className='flex items-center justify-center h-12 bg-gray-50/80 rounded-lg border border-gray-200/50 dark:bg-gray-800 dark:border-gray-700'>
<Loader2 className='h-5 w-5 animate-spin text-gray-400' />
<span className='ml-2 text-sm text-gray-500 dark:text-gray-400'>
...
</span>
</div>
) : sources.length === 0 ? (
<div className='flex items-center justify-center h-24 rounded-xl border border-dashed border-gray-300 bg-gray-50/70 text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400'>
</div>
) : (
<div className='flex justify-center'>
<CapsuleSwitch
options={sources.map((item) => ({
label: item.name,
value: item.key,
}))}
active={selectedSource}
onChange={setSelectedSource}
/>
</div>
)}
</div>
{!!error && (
<div className='rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600 dark:border-red-900/50 dark:bg-red-900/10 dark:text-red-300'>
{error}
</div>
)}
{!isLoadingSources && sources.length > 0 && (
<>
{videos.length > 0 ? (
<div className='grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3 sm:gap-4'>
{videos.map((video, index) => (
<VideoCard
key={`${video.source}-${video.id}-${index}`}
id={video.id}
source={video.source}
source_name={video.source_name}
title={video.title}
poster={video.poster}
year={video.year}
rate={
typeof video.rating === 'number'
? String(video.rating)
: undefined
}
douban_id={video.douban_id}
tmdb_id={video.tmdb_id}
from='source-search'
/>
))}
</div>
) : !isLoadingVideos && !error ? (
<div className='flex items-center justify-center h-32 rounded-xl border border-dashed border-gray-300 bg-gray-50/70 text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400'>
</div>
) : null}
{isLoadingVideos && (
<div className='flex justify-center py-6'>
<Loader2 className='h-6 w-6 animate-spin text-gray-400' />
</div>
)}
<div ref={loadMoreRef} className='h-10' />
</>
)}
</div>
</div>
</PageLayout>
);
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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:

View File

@@ -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({

View File

@@ -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({

View File

@@ -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;