feat: search api/page && detail api/page

This commit is contained in:
shinya
2025-06-18 00:59:11 +08:00
parent b38b513df0
commit 479fafcb1c
15 changed files with 1050 additions and 376 deletions

219
src/app/api/detail/route.ts Normal file
View File

@@ -0,0 +1,219 @@
import { NextResponse } from 'next/server';
import { API_CONFIG, ApiSite, getApiSites } from '@/lib/config';
const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g;
export interface VideoDetail {
code: number;
episodes: string[];
detailUrl: string;
videoInfo: {
title: string;
cover?: string;
desc?: string;
type?: string;
year?: string;
area?: string;
director?: string;
actor?: string;
remarks?: string;
source_name: string;
source: string;
id: string;
};
}
async function handleSpecialSourceDetail(
id: string,
apiSite: ApiSite
): Promise<VideoDetail> {
const detailUrl = `${apiSite.detail}/index.php/vod/detail/id/${id}.html`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(detailUrl, {
headers: API_CONFIG.detail.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`详情页请求失败: ${response.status}`);
}
const html = await response.text();
let matches: string[] = [];
if (apiSite.key === 'ffzy') {
const ffzyPattern =
/\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g;
matches = html.match(ffzyPattern) || [];
}
if (matches.length === 0) {
const generalPattern = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
matches = html.match(generalPattern) || [];
}
matches = Array.from(new Set(matches));
matches = matches.map((link: string) => {
link = link.substring(1, link.length);
const parenIndex = link.indexOf('(');
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
});
const titleMatch = html.match(/<h1[^>]*>([^<]+)<\/h1>/);
const titleText = titleMatch ? titleMatch[1].trim() : '';
const descMatch = html.match(
/<div[^>]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/
);
const descText = descMatch
? descMatch[1].replace(/<[^>]+>/g, ' ').trim()
: '';
const coverMatch = html.match(/(https?:\/\/[^"'\s]+?\.jpg)/g);
const coverUrl = coverMatch ? coverMatch[0].trim() : '';
return {
code: 200,
episodes: matches,
detailUrl: detailUrl,
videoInfo: {
title: titleText,
cover: coverUrl,
desc: descText,
source_name: apiSite.name,
source: apiSite.key,
id: id,
},
};
}
async function getDetailFromApi(
apiSite: ApiSite,
id: string
): Promise<VideoDetail> {
const detailUrl = `${apiSite.api}${API_CONFIG.detail.path}${id}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(detailUrl, {
headers: API_CONFIG.detail.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`详情请求失败: ${response.status}`);
}
const data = await response.json();
if (
!data ||
!data.list ||
!Array.isArray(data.list) ||
data.list.length === 0
) {
throw new Error('获取到的详情内容无效');
}
const videoDetail = data.list[0];
let episodes: string[] = [];
if (videoDetail.vod_play_url) {
const playSources = videoDetail.vod_play_url.split('$$$');
if (playSources.length > 0) {
const mainSource = playSources[0];
const episodeList = mainSource.split('#');
episodes = episodeList
.map((ep: string) => {
const parts = ep.split('$');
return parts.length > 1 ? parts[1] : '';
})
.filter(
(url: string) =>
url && (url.startsWith('http://') || url.startsWith('https://'))
);
}
}
if (episodes.length === 0 && videoDetail.vod_content) {
const matches = videoDetail.vod_content.match(M3U8_PATTERN) || [];
episodes = matches.map((link: string) => link.replace(/^\$/, ''));
}
return {
code: 200,
episodes: episodes,
detailUrl: detailUrl,
videoInfo: {
title: videoDetail.vod_name,
cover: videoDetail.vod_pic,
desc: videoDetail.vod_content,
type: videoDetail.type_name,
year: videoDetail.vod_year,
area: videoDetail.vod_area,
director: videoDetail.vod_director,
actor: videoDetail.vod_actor,
remarks: videoDetail.vod_remarks,
source_name: apiSite.name,
source: apiSite.key,
id: id,
},
};
}
export async function getVideoDetail(
id: string,
sourceCode: string
): Promise<VideoDetail> {
if (!id) {
throw new Error('缺少视频ID参数');
}
if (!/^[\w-]+$/.test(id)) {
throw new Error('无效的视频ID格式');
}
const apiSites = getApiSites();
const apiSite = apiSites.find((site) => site.key === sourceCode);
if (!apiSite) {
throw new Error('无效的API来源');
}
if (apiSite.detail) {
return await handleSpecialSourceDetail(id, apiSite);
} else {
return await getDetailFromApi(apiSite, id);
}
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
const sourceCode = searchParams.get('source');
if (!id || !sourceCode) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
}
try {
const result = await getVideoDetail(id, sourceCode);
return NextResponse.json(result);
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
);
}
}

View File

@@ -1,5 +0,0 @@
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({ hello: 'Next.js' });
}

177
src/app/api/search/route.ts Normal file
View File

@@ -0,0 +1,177 @@
import { NextResponse } from 'next/server';
import { API_CONFIG, ApiSite, getApiSites } from '@/lib/config';
import { getVideoDetail } from '../detail/route';
interface SearchResult {
id: string;
title: string;
poster: string;
episodes?: number;
source: string;
source_name: string;
}
interface ApiSearchItem {
vod_id: string;
vod_name: string;
vod_pic: string;
vod_remarks?: string;
}
async function searchFromApi(
apiSite: ApiSite,
query: string
): Promise<SearchResult[]> {
try {
const apiBaseUrl = apiSite.api;
const apiUrl =
apiBaseUrl + API_CONFIG.search.path + encodeURIComponent(query);
const apiName = apiSite.name;
// 添加超时处理
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
const response = await fetch(apiUrl, {
headers: API_CONFIG.search.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
return [];
}
const data = await response.json();
if (
!data ||
!data.list ||
!Array.isArray(data.list) ||
data.list.length === 0
) {
return [];
}
// 处理第一页结果
const results = data.list.map((item: ApiSearchItem) => ({
id: item.vod_id,
title: item.vod_name,
poster: item.vod_pic,
episodes: item.vod_remarks ? parseInt(item.vod_remarks) : undefined,
source: apiSite.key,
source_name: apiName,
}));
// 获取总页数
const pageCount = data.pagecount || 1;
// 确定需要获取的额外页数
const pagesToFetch = Math.min(
pageCount - 1,
API_CONFIG.search.maxPages - 1
);
// 如果有额外页数,获取更多页的结果
if (pagesToFetch > 0) {
const additionalPagePromises = [];
for (let page = 2; page <= pagesToFetch + 1; page++) {
const pageUrl =
apiBaseUrl +
API_CONFIG.search.pagePath
.replace('{query}', encodeURIComponent(query))
.replace('{page}', page.toString());
const pagePromise = (async () => {
try {
const pageController = new AbortController();
const pageTimeoutId = setTimeout(
() => pageController.abort(),
8000
);
const pageResponse = await fetch(pageUrl, {
headers: API_CONFIG.search.headers,
signal: pageController.signal,
});
clearTimeout(pageTimeoutId);
if (!pageResponse.ok) return [];
const pageData = await pageResponse.json();
if (!pageData || !pageData.list || !Array.isArray(pageData.list))
return [];
return pageData.list.map((item: ApiSearchItem) => ({
id: item.vod_id,
title: item.vod_name,
poster: item.vod_pic,
episodes: item.vod_remarks
? parseInt(item.vod_remarks)
: undefined,
source: apiSite.key,
source_name: apiName,
}));
} catch (error) {
return [];
}
})();
additionalPagePromises.push(pagePromise);
}
// 等待所有额外页的结果
const additionalResults = await Promise.all(additionalPagePromises);
// 合并所有页的结果
additionalResults.forEach((pageResults) => {
if (pageResults.length > 0) {
results.push(...pageResults);
}
});
}
// 获取每个结果的详情
const detailPromises = results.map(async (result: SearchResult) => {
try {
const detail = await getVideoDetail(result.id, result.source);
if (detail.episodes.length > 1) {
return { ...result, episodes: detail.episodes.length };
}
return result;
} catch (error) {
return result;
}
});
return await Promise.all(detailPromises);
} catch (error) {
return [];
}
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const query = searchParams.get('q');
if (!query) {
return NextResponse.json({ results: [] });
}
const apiSites = getApiSites();
const searchPromises = apiSites.map((site) => searchFromApi(site, query));
try {
const results = await Promise.all(searchPromises);
const flattenedResults = results.flat();
return NextResponse.json({ results: flattenedResults });
} catch (error) {
return NextResponse.json({ error: '搜索失败' }, { status: 500 });
}
}

158
src/app/detail/page.tsx Normal file
View File

@@ -0,0 +1,158 @@
'use client';
import Image from 'next/image';
import { useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import Sidebar from '@/components/layout/Sidebar';
import { VideoDetail } from '../api/detail/route';
export default function DetailPage() {
const searchParams = useSearchParams();
const [detail, setDetail] = useState<VideoDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const source = searchParams.get('source');
const id = searchParams.get('id');
if (!source || !id) {
setError('缺少必要参数');
setLoading(false);
return;
}
const fetchDetail = async () => {
try {
const response = await fetch(`/api/detail?source=${source}&id=${id}`);
if (!response.ok) {
throw new Error('获取详情失败');
}
const data = await response.json();
setDetail(data);
} catch (err) {
setError(err instanceof Error ? err.message : '获取详情失败');
} finally {
setLoading(false);
}
};
fetchDetail();
}, [searchParams]);
return (
<div className='flex min-h-screen'>
<Sidebar activePath='/detail' />
<main className='flex-1 p-8 flex flex-col items-center'>
{loading ? (
<div className='flex items-center justify-center min-h-screen'>
<div className='animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500'></div>
</div>
) : error ? (
<div className='flex items-center justify-center min-h-screen'>
<div className='text-red-500'>{error}</div>
</div>
) : !detail ? (
<div className='flex items-center justify-center min-h-screen'>
<div className='text-gray-500'></div>
</div>
) : (
<div className='w-full max-w-[90%]'>
{/* 主信息区:左图右文 */}
<div className='flex flex-col md:flex-row gap-8 mb-8 bg-transparent rounded-xl p-6'>
{/* 封面 */}
<div className='flex-shrink-0 w-full md:w-64'>
<Image
src={detail.videoInfo.cover || '/images/placeholder.png'}
alt={detail.videoInfo.title}
width={256}
height={384}
className='w-full rounded-xl object-cover'
style={{ aspectRatio: '2/3' }}
priority
/>
</div>
{/* 右侧信息 */}
<div className='flex-1 flex flex-col justify-between'>
<div>
<h1 className='text-3xl font-bold mb-2 tracking-wide flex items-center'>
{detail.videoInfo.title}
</h1>
<div className='flex flex-wrap items-center gap-3 text-base mb-4 opacity-80'>
{detail.videoInfo.remarks && (
<span className='text-red-500 font-semibold'>
{detail.videoInfo.remarks}
</span>
)}
{detail.videoInfo.year && (
<span>{detail.videoInfo.year}</span>
)}
{detail.videoInfo.source_name && (
<span>{detail.videoInfo.source_name}</span>
)}
{detail.videoInfo.type && (
<span>{detail.videoInfo.type}</span>
)}
</div>
<div className='flex items-center gap-4 mb-4'>
<button className='flex items-center justify-center gap-2 px-6 py-2 bg-gray-500/40 hover:bg-[#22c55e] rounded-lg transition-colors text-white'>
<div className='w-0 h-0 border-t-[6px] border-t-transparent border-l-[10px] border-l-white border-b-[6px] border-b-transparent'></div>
<span></span>
</button>
<button className='flex items-center justify-center w-10 h-10 bg-gray-500/40 hover:bg-[#22c55e] rounded-full transition-colors'>
<svg
xmlns='http://www.w3.org/2000/svg'
className='h-5 w-5 text-white'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z'
/>
</svg>
</button>
</div>
{detail.videoInfo.desc && (
<div className='mt-4 text-base leading-relaxed opacity-90 max-h-40 overflow-y-auto pr-2'>
{detail.videoInfo.desc}
</div>
)}
</div>
</div>
</div>
{/* 选集按钮区 */}
{detail.episodes.length > 0 && (
<div className='mt-8 bg-transparent rounded-xl p-6'>
<div className='flex items-center gap-2 mb-4'>
<div className='text-xl font-semibold'></div>
<div className='text-gray-400 ml-2'>
{detail.episodes.length}
</div>
</div>
<div className='flex flex-wrap gap-3'>
{detail.episodes.map((episode, idx) => (
<a
key={idx}
href={episode}
target='_blank'
rel='noopener noreferrer'
className='bg-gray-500/40 hover:bg-[#22c55e] text-white px-5 py-2 rounded-lg shadow transition-colors text-base font-medium w-24 text-center'
>
{idx + 1}
</a>
))}
</div>
</div>
)}
</div>
)}
</main>
</div>
);
}

View File

@@ -3,9 +3,9 @@
import { useState } from 'react';
import CapsuleSwitch from '@/components/CapsuleSwitch';
import DemoCard from '@/components/card/DemoCard';
import VideoCard from '@/components/card/VideoCard';
import Sidebar from '@/components/layout/Sidebar';
import DemoCard from '@/components/DemoCard';
import PageLayout from '@/components/layout/PageLayout';
import VideoCard from '@/components/VideoCard';
const defaultPoster =
'https://vip.dytt-img.com/upload/vod/20250326-1/9857e2e8581f231e24747ee32e633a3b.jpg';
@@ -14,57 +14,46 @@ const defaultPoster =
const mockData = {
recentMovies: [
{
id: 1,
id: '1',
title: '流浪地球2',
poster: defaultPoster,
rating: 8.3,
type: 'movie' as const,
source: '电影天堂',
source: 'dyttzy',
source_name: '电影天堂',
},
{
id: 2,
id: '2',
title: '满江红',
poster: defaultPoster,
rating: 7.5,
type: 'movie' as const,
source: '电影天堂',
source: 'dyttzy',
source_name: '电影天堂',
},
],
recentTvShows: [
{
id: 3,
id: '3',
title: '三体',
poster: defaultPoster,
rating: 8.7,
type: 'tv' as const,
source: 'dyttzy',
source_name: '电影天堂',
episodes: 30,
source: '电影天堂',
},
{
id: 4,
id: '4',
title: '狂飙',
poster: defaultPoster,
rating: 8.5,
type: 'tv' as const,
episodes: 39,
source: '电影天堂',
source: 'dyttzy',
source_name: '电影天堂',
},
],
};
export default function Home() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [activeTab, setActiveTab] = useState('home'); // 'home' 或 'favorites'
const [activeTab, setActiveTab] = useState('home');
return (
<div className='flex min-h-screen'>
<Sidebar onToggle={setSidebarCollapsed} />
<main
className={`flex-1 px-10 py-8 transition-all duration-300 ${
sidebarCollapsed ? 'ml-16' : 'ml-64'
}`}
>
<PageLayout>
<div className='px-10 py-8'>
{/* 顶部 Tab 切换 */}
<div className='mb-8 flex justify-center'>
<CapsuleSwitch
@@ -77,54 +66,52 @@ export default function Home() {
/>
</div>
{/* 继续观看 */}
<section className='mb-12'>
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
</h2>
<div className='flex space-x-8 overflow-x-auto pb-2'>
{[...mockData.recentMovies, ...mockData.recentTvShows]
.slice(0, 4)
.map((item) => (
<div key={item.id} className='min-w-[192px] w-48'>
<VideoCard
{...item}
showProgress={true}
progress={Math.random() * 100}
/>
<div className='max-w-[90%] mx-auto'>
{/* 继续观看 */}
<section className='mb-12'>
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
</h2>
<div className='flex space-x-8 overflow-x-auto pb-2'>
{[...mockData.recentMovies, ...mockData.recentTvShows]
.slice(0, 4)
.map((item) => (
<div key={item.id} className='min-w-[192px] w-48'>
<VideoCard {...item} progress={Math.random() * 100} />
</div>
))}
</div>
</section>
{/* 最新电影 */}
<section className='mb-12'>
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
</h2>
<div className='grid grid-cols-5 gap-8'>
{mockData.recentMovies.map((movie) => (
<div key={movie.id} className='w-48'>
<DemoCard {...movie} />
</div>
))}
</div>
</section>
</div>
</section>
{/* 最新电 */}
<section className='mb-12'>
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
</h2>
<div className='grid grid-cols-5 gap-8'>
{mockData.recentMovies.map((movie) => (
<div key={movie.id} className='w-48'>
<DemoCard {...movie} />
</div>
))}
</div>
</section>
{/* 最新电视剧 */}
<section className='mb-12'>
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
</h2>
<div className='grid grid-cols-5 gap-8'>
{mockData.recentTvShows.map((show) => (
<div key={show.id} className='w-48'>
<DemoCard {...show} />
</div>
))}
</div>
</section>
</main>
</div>
{/* 最新电视剧 */}
<section className='mb-12'>
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
</h2>
<div className='grid grid-cols-5 gap-8'>
{mockData.recentTvShows.map((show) => (
<div key={show.id} className='w-48'>
<DemoCard {...show} />
</div>
))}
</div>
</section>
</div>
</div>
</PageLayout>
);
}

View File

@@ -4,42 +4,29 @@ import { Search } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import VideoCard from '@/components/card/VideoCard';
import Sidebar from '@/components/layout/Sidebar';
import PageLayout from '@/components/layout/PageLayout';
import VideoCard from '@/components/VideoCard';
// 模拟搜索历史数据
const mockSearchHistory = ['流浪地球', '三体', '狂飙', '满江红'];
// 模拟搜索结果数据
const mockSearchResults = [
{
id: 1,
title: '流浪地球2',
poster:
'https://vip.dytt-img.com/upload/vod/20250326-1/9857e2e8581f231e24747ee32e633a3b.jpg',
type: 'movie' as const,
source: '电影天堂',
},
{
id: 2,
title: '三体',
poster:
'https://vip.dytt-img.com/upload/vod/20250326-1/9857e2e8581f231e24747ee32e633a3b.jpg',
type: 'tv' as const,
episodes: 30,
source: '电影天堂',
},
];
// 定义搜索结果类型
type SearchResult = {
id: string;
title: string;
poster: string;
source: string;
source_name: string;
episodes?: number;
};
export default function SearchPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [searchQuery, setSearchQuery] = useState(searchParams.get('q') || '');
const [searchResults, setSearchResults] = useState<typeof mockSearchResults>(
[]
);
const [searchQuery, setSearchQuery] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showResults, setShowResults] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const searchInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -52,30 +39,43 @@ export default function SearchPage() {
const query = searchParams.get('q');
if (query) {
setSearchQuery(query);
// 这里应该调用实际的搜索 API
setSearchResults(mockSearchResults);
setShowResults(true);
fetchSearchResults(query);
} else {
setShowResults(false);
}
}, [searchParams]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim()) {
router.push(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
const fetchSearchResults = async (query: string) => {
try {
setIsLoading(true);
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`
);
const data = await response.json();
setSearchResults(data.results);
setShowResults(true);
} catch (error) {
setSearchResults([]);
} finally {
setIsLoading(false);
}
};
return (
<div className='flex min-h-screen'>
<Sidebar onToggle={setSidebarCollapsed} activePath='/search' />
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (!searchQuery.trim()) return;
<main
className={`flex-1 px-10 py-8 transition-all duration-300 ${
sidebarCollapsed ? 'ml-16' : 'ml-64'
}`}
>
setIsLoading(true);
setShowResults(true);
// 模拟搜索延迟
setTimeout(() => {
fetchSearchResults(searchQuery);
}, 1000);
};
return (
<PageLayout activePath='/search'>
<div className='px-10 py-8'>
{/* 搜索框 */}
<div className='mb-8'>
<form onSubmit={handleSearch} className='max-w-2xl mx-auto'>
@@ -94,15 +94,24 @@ export default function SearchPage() {
</div>
{/* 搜索结果或搜索历史 */}
<div className='max-w-7xl mx-auto mt-12'>
{showResults ? (
<div className='max-w-[90%] mx-auto mt-12'>
{isLoading ? (
<div className='flex justify-center items-center h-40'>
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
</div>
) : showResults ? (
// 搜索结果
<div className='grid grid-cols-5 gap-7'>
<div className='grid grid-cols-6 gap-7'>
{searchResults.map((item) => (
<div key={item.id} className='w-44'>
<VideoCard {...item} />
</div>
))}
{searchResults.length === 0 && (
<div className='col-span-5 text-center text-gray-500 py-8'>
</div>
)}
</div>
) : mockSearchHistory.length > 0 ? (
// 搜索历史
@@ -127,7 +136,7 @@ export default function SearchPage() {
</section>
) : null}
</div>
</main>
</div>
</div>
</PageLayout>
);
}