高级推荐
This commit is contained in:
229
src/app/advanced-recommendation/page.tsx
Normal file
229
src/app/advanced-recommendation/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/app/api/advanced-recommendation/sources/route.ts
Normal file
30
src/app/api/advanced-recommendation/sources/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
66
src/app/api/advanced-recommendation/videos/route.ts
Normal file
66
src/app/api/advanced-recommendation/videos/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user