mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-21 09:14:42 +08:00
feat: enhance video match
This commit is contained in:
10
config.json
10
config.json
@@ -10,6 +10,11 @@
|
||||
"api": "https://cj.rycjapi.com/api.php/provide/vod",
|
||||
"name": "如意资源"
|
||||
},
|
||||
"heimuer": {
|
||||
"api": "https://json.heimuer.xyz/api.php/provide/vod",
|
||||
"name": "黑木耳",
|
||||
"detail": "https://heimuer.tv"
|
||||
},
|
||||
"bfzy": {
|
||||
"api": "https://bfzyapi.com/api.php/provide/vod",
|
||||
"name": "暴风资源"
|
||||
@@ -23,11 +28,6 @@
|
||||
"name": "非凡影视",
|
||||
"detail": "http://ffzy5.tv"
|
||||
},
|
||||
"heimuer": {
|
||||
"api": "https://json.heimuer.xyz/api.php/provide/vod",
|
||||
"name": "黑木耳",
|
||||
"detail": "https://heimuer.tv"
|
||||
},
|
||||
"zy360": {
|
||||
"api": "https://360zy.com/api.php/provide/vod",
|
||||
"name": "360资源"
|
||||
|
||||
@@ -3,31 +3,34 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
|
||||
import type { VideoDetail } from '@/lib/types';
|
||||
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
episodes?: number;
|
||||
episodes: string[];
|
||||
source: string;
|
||||
source_name: string;
|
||||
class?: string;
|
||||
year: string;
|
||||
desc?: string;
|
||||
type_name?: string;
|
||||
}
|
||||
|
||||
function AggregatePageClient() {
|
||||
const searchParams = useSearchParams();
|
||||
const query = searchParams.get('q')?.trim() || '';
|
||||
const title = searchParams.get('title')?.trim() || '';
|
||||
const year = searchParams.get('year')?.trim() || '';
|
||||
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [details, setDetails] = useState<VideoDetail[]>([]);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!query) {
|
||||
@@ -44,8 +47,27 @@ function AggregatePageClient() {
|
||||
}
|
||||
const data = await res.json();
|
||||
const all: SearchResult[] = data.results || [];
|
||||
const exact = all.filter((r) => r.title === query);
|
||||
setResults(exact);
|
||||
const map = new Map<string, SearchResult[]>();
|
||||
all.forEach((r) => {
|
||||
// 根据传入参数进行精确匹配:
|
||||
// 1. 如果提供了 title,则按 title 精确匹配,否则按 query 精确匹配;
|
||||
// 2. 如果还提供了 year,则额外按 year 精确匹配。
|
||||
const titleMatch = title ? r.title === title : r.title === query;
|
||||
const yearMatch = year ? r.year === year : true;
|
||||
if (!titleMatch || !yearMatch) {
|
||||
return;
|
||||
}
|
||||
const key = `${r.title}-${r.year}`;
|
||||
const arr = map.get(key) || [];
|
||||
arr.push(r);
|
||||
map.set(key, arr);
|
||||
});
|
||||
if (map.size == 1) {
|
||||
setResults(Array.from(map.values()).flat());
|
||||
} else if (map.size > 1) {
|
||||
// 存在多个匹配,跳转到搜索页
|
||||
router.push(`/search?q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '搜索失败');
|
||||
} finally {
|
||||
@@ -54,37 +76,7 @@ function AggregatePageClient() {
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (results.length === 0) return;
|
||||
|
||||
const fetchDetails = async () => {
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const promises = results.map(async (r) => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/detail?source=${r.source}&id=${r.id}`
|
||||
);
|
||||
if (!res.ok) throw new Error('');
|
||||
const data: VideoDetail = await res.json();
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const dts = (await Promise.all(promises)).filter(
|
||||
(d): d is VideoDetail => d !== null
|
||||
);
|
||||
setDetails(dts);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDetails();
|
||||
}, [results]);
|
||||
}, [query, router]);
|
||||
|
||||
// 选出信息最完整的字段
|
||||
const chooseString = (vals: (string | undefined)[]): string | undefined => {
|
||||
@@ -96,12 +88,12 @@ function AggregatePageClient() {
|
||||
};
|
||||
|
||||
const aggregatedInfo = {
|
||||
title: query,
|
||||
cover: chooseString(details.map((d) => d.videoInfo.cover)),
|
||||
desc: chooseString(details.map((d) => d.videoInfo.desc)),
|
||||
type: chooseString(details.map((d) => d.videoInfo.type)),
|
||||
year: chooseString(details.map((d) => d.videoInfo.year)),
|
||||
remarks: chooseString(details.map((d) => d.videoInfo.remarks)),
|
||||
title: title || query,
|
||||
cover: chooseString(results.map((d) => d.poster)),
|
||||
desc: chooseString(results.map((d) => d.desc)),
|
||||
type: chooseString(results.map((d) => d.type_name)),
|
||||
year: chooseString(results.map((d) => d.year)),
|
||||
remarks: chooseString(results.map((d) => d.class)),
|
||||
};
|
||||
|
||||
const infoReady = Boolean(
|
||||
@@ -117,7 +109,7 @@ function AggregatePageClient() {
|
||||
);
|
||||
|
||||
// 详情映射,便于快速获取每个源的集数
|
||||
const sourceDetailMap = new Map(details.map((d) => [d.videoInfo.source, d]));
|
||||
const sourceDetailMap = new Map(results.map((d) => [d.source, d]));
|
||||
|
||||
return (
|
||||
<PageLayout activePath='/aggregate'>
|
||||
@@ -133,10 +125,6 @@ function AggregatePageClient() {
|
||||
<div className='text-sm'>{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !infoReady && detailLoading ? (
|
||||
<div className='flex items-center justify-center min-h-[60vh]'>
|
||||
<div className='animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500'></div>
|
||||
</div>
|
||||
) : !infoReady ? (
|
||||
<div className='flex items-center justify-center min-h-[60vh]'>
|
||||
<div className='text-gray-500 text-center'>
|
||||
@@ -219,15 +207,15 @@ function AggregatePageClient() {
|
||||
<div className='grid grid-cols-3 gap-2 sm:grid-cols-[repeat(auto-fill,_minmax(6rem,_1fr))] sm:gap-4 justify-start'>
|
||||
{uniqueSources.map((src) => {
|
||||
const d = sourceDetailMap.get(src.source);
|
||||
const epCount = d ? d.episodes.length : src.episodes;
|
||||
const epCount = d ? d.episodes.length : src.episodes.length;
|
||||
return (
|
||||
<a
|
||||
key={src.source}
|
||||
href={`/play?source=${src.source}&id=${
|
||||
src.id
|
||||
}&title=${encodeURIComponent(
|
||||
src.title
|
||||
)}&from=aggregate`}
|
||||
}&title=${encodeURIComponent(src.title)}${
|
||||
src.year ? `&year=${src.year}` : ''
|
||||
}&from=aggregate`}
|
||||
className='relative flex items-center justify-center w-full h-14 bg-gray-500/80 hover:bg-green-500 rounded-lg transition-colors'
|
||||
>
|
||||
{/* 名称 */}
|
||||
|
||||
@@ -2,22 +2,11 @@ import { NextResponse } from 'next/server';
|
||||
|
||||
import { API_CONFIG, ApiSite, getApiSites, getCacheTime } from '@/lib/config';
|
||||
import { VideoDetail } from '@/lib/types';
|
||||
import { cleanHtmlTags } from '@/lib/utils';
|
||||
|
||||
// 匹配 m3u8 链接的正则
|
||||
const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g;
|
||||
|
||||
// 清理 HTML 标签的工具函数
|
||||
function cleanHtmlTags(text: string): string {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/<[^>]+>/g, '\n') // 将 HTML 标签替换为换行
|
||||
.replace(/\n+/g, '\n') // 将多个连续换行合并为一个
|
||||
.replace(/[ \t]+/g, ' ') // 将多个连续空格和制表符合并为一个空格,但保留换行符
|
||||
.replace(/^\n+|\n+$/g, '') // 去掉首尾换行
|
||||
.replace(/ /g, ' ') // 将 替换为空格
|
||||
.trim(); // 去掉首尾空格
|
||||
}
|
||||
|
||||
async function handleSpecialSourceDetail(
|
||||
id: string,
|
||||
apiSite: ApiSite
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { API_CONFIG, ApiSite, getApiSites, getCacheTime } from '@/lib/config';
|
||||
import { cleanHtmlTags } from '@/lib/utils';
|
||||
|
||||
export interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
episodes?: number;
|
||||
episodes: string[];
|
||||
source: string;
|
||||
source_name: string;
|
||||
class?: string;
|
||||
year: string;
|
||||
desc?: string;
|
||||
type_name?: string;
|
||||
}
|
||||
|
||||
interface ApiSearchItem {
|
||||
@@ -17,6 +22,10 @@ interface ApiSearchItem {
|
||||
vod_pic: string;
|
||||
vod_remarks?: string;
|
||||
vod_play_url?: string;
|
||||
vod_class?: string;
|
||||
vod_year?: string;
|
||||
vod_content?: string;
|
||||
type_name?: string;
|
||||
}
|
||||
|
||||
async function searchFromApi(
|
||||
@@ -54,18 +63,30 @@ async function searchFromApi(
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 处理第一页结果
|
||||
const results = data.list.map((item: ApiSearchItem) => {
|
||||
let episodes: number | undefined = undefined;
|
||||
let episodes: string[] = [];
|
||||
|
||||
// 使用正则表达式从 vod_play_url 提取 m3u8 链接
|
||||
if (item.vod_play_url) {
|
||||
const m3u8Regex = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
|
||||
const matches = item.vod_play_url.match(m3u8Regex);
|
||||
episodes = matches ? matches.length : undefined;
|
||||
// 先用 $$$ 分割
|
||||
const vod_play_url_array = item.vod_play_url.split('$$$');
|
||||
// 对每个分片做匹配,取匹配到最多的作为结果
|
||||
vod_play_url_array.forEach((url: string) => {
|
||||
const matches = url.match(m3u8Regex) || [];
|
||||
if (matches.length > episodes.length) {
|
||||
episodes = matches;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
episodes = Array.from(new Set(episodes)).map((link: string) => {
|
||||
link = link.substring(1); // 去掉开头的 $
|
||||
const parenIndex = link.indexOf('(');
|
||||
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
|
||||
});
|
||||
|
||||
return {
|
||||
id: item.vod_id,
|
||||
title: item.vod_name,
|
||||
@@ -73,6 +94,10 @@ async function searchFromApi(
|
||||
episodes,
|
||||
source: apiSite.key,
|
||||
source_name: apiName,
|
||||
class: item.vod_class,
|
||||
year: item.vod_year,
|
||||
desc: cleanHtmlTags(item.vod_content || ''),
|
||||
type_name: item.type_name,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -118,15 +143,20 @@ async function searchFromApi(
|
||||
return [];
|
||||
|
||||
return pageData.list.map((item: ApiSearchItem) => {
|
||||
let episodes: number | undefined = undefined;
|
||||
let episodes: string[] = [];
|
||||
|
||||
// 使用正则表达式从 vod_play_url 提取 m3u8 链接
|
||||
if (item.vod_play_url) {
|
||||
const m3u8Regex = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
|
||||
const matches = item.vod_play_url.match(m3u8Regex);
|
||||
episodes = matches ? matches.length : undefined;
|
||||
episodes = item.vod_play_url.match(m3u8Regex) || [];
|
||||
}
|
||||
|
||||
episodes = Array.from(new Set(episodes)).map((link: string) => {
|
||||
link = link.substring(1); // 去掉开头的 $
|
||||
const parenIndex = link.indexOf('(');
|
||||
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
|
||||
});
|
||||
|
||||
return {
|
||||
id: item.vod_id,
|
||||
title: item.vod_name,
|
||||
@@ -134,6 +164,10 @@ async function searchFromApi(
|
||||
episodes,
|
||||
source: apiSite.key,
|
||||
source_name: apiName,
|
||||
class: item.vod_class,
|
||||
year: item.vod_year,
|
||||
desc: cleanHtmlTags(item.vod_content || ''),
|
||||
type_name: item.type_name,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
isFavorited,
|
||||
toggleFavorite,
|
||||
} from '@/lib/db.client';
|
||||
import { VideoDetail } from '@/lib/types';
|
||||
import { type VideoDetail, fetchVideoDetail } from '@/lib/fetchVideoDetail';
|
||||
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
|
||||
@@ -26,8 +26,8 @@ function DetailPageClient() {
|
||||
const [playRecord, setPlayRecord] = useState<PlayRecord | null>(null);
|
||||
const [favorited, setFavorited] = useState(false);
|
||||
|
||||
// 当接口缺失标题时,使用 URL 中的 title 参数作为后备
|
||||
const fallbackTitle = searchParams.get('title') || '';
|
||||
const fallbackYear = searchParams.get('year') || '';
|
||||
|
||||
// 格式化剩余时间(如 1h 50m)
|
||||
const formatDuration = (seconds: number) => {
|
||||
@@ -52,20 +52,14 @@ function DetailPageClient() {
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/detail?source=${source}&id=${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('获取详情失败');
|
||||
}
|
||||
const data = await response.json();
|
||||
// 如果接口中缺失标题,则补上备用标题
|
||||
let finalData = data;
|
||||
if (!data?.videoInfo?.title && fallbackTitle) {
|
||||
finalData = {
|
||||
...data,
|
||||
videoInfo: { ...data.videoInfo, title: fallbackTitle },
|
||||
};
|
||||
}
|
||||
setDetail(finalData);
|
||||
// 获取视频详情
|
||||
const detailData = await fetchVideoDetail({
|
||||
source,
|
||||
id,
|
||||
fallbackTitle,
|
||||
fallbackYear,
|
||||
});
|
||||
setDetail(detailData);
|
||||
|
||||
// 获取播放记录
|
||||
const allRecords = await getAllPlayRecords();
|
||||
@@ -97,10 +91,11 @@ function DetailPageClient() {
|
||||
|
||||
try {
|
||||
const newState = await toggleFavorite(source, id, {
|
||||
title: detail.videoInfo.title,
|
||||
source_name: detail.videoInfo.source_name,
|
||||
cover: detail.videoInfo.cover || '',
|
||||
total_episodes: detail.episodes?.length || 1,
|
||||
title: detail.title,
|
||||
source_name: detail.source_name,
|
||||
year: detail.year || fallbackYear || '',
|
||||
cover: detail.poster || '',
|
||||
total_episodes: detail.episodes.length || 1,
|
||||
save_time: Date.now(),
|
||||
});
|
||||
setFavorited(newState);
|
||||
@@ -137,12 +132,7 @@ function DetailPageClient() {
|
||||
{/* 返回按钮放置在主信息区左上角 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const from = searchParams.get('from');
|
||||
if (from === 'search') {
|
||||
window.history.back();
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
window.history.back();
|
||||
}}
|
||||
className='absolute top-0 left-0 -translate-x-[40%] -translate-y-[30%] sm:-translate-x-[180%] sm:-translate-y-1/2 p-2 rounded transition-colors'
|
||||
>
|
||||
@@ -164,8 +154,8 @@ function DetailPageClient() {
|
||||
{/* 封面 */}
|
||||
<div className='flex-shrink-0 w-full max-w-[200px] sm:max-w-none md:w-72 mx-auto'>
|
||||
<Image
|
||||
src={detail.videoInfo.cover || '/images/placeholder.png'}
|
||||
alt={detail.videoInfo.title || fallbackTitle}
|
||||
src={detail.poster || '/images/placeholder.png'}
|
||||
alt={detail.title || fallbackTitle}
|
||||
width={288}
|
||||
height={432}
|
||||
className='w-full rounded-xl object-cover'
|
||||
@@ -180,25 +170,23 @@ function DetailPageClient() {
|
||||
style={{ height: '430px' }}
|
||||
>
|
||||
<h1 className='text-3xl font-bold mb-2 tracking-wide flex items-center flex-shrink-0 text-center md:text-left w-full'>
|
||||
{detail.videoInfo.title || fallbackTitle}
|
||||
{detail.title || fallbackTitle}
|
||||
</h1>
|
||||
<div className='flex flex-wrap items-center gap-3 text-base mb-4 opacity-80 flex-shrink-0'>
|
||||
{detail.videoInfo.remarks && (
|
||||
{detail.class && (
|
||||
<span className='text-green-600 font-semibold'>
|
||||
{detail.videoInfo.remarks}
|
||||
{detail.class}
|
||||
</span>
|
||||
)}
|
||||
{detail.videoInfo.year && (
|
||||
<span>{detail.videoInfo.year}</span>
|
||||
{(detail.year || fallbackYear) && (
|
||||
<span>{detail.year || fallbackYear}</span>
|
||||
)}
|
||||
{detail.videoInfo.source_name && (
|
||||
{detail.source_name && (
|
||||
<span className='border border-gray-500/60 px-2 py-[1px] rounded'>
|
||||
{detail.videoInfo.source_name}
|
||||
{detail.source_name}
|
||||
</span>
|
||||
)}
|
||||
{detail.videoInfo.type && (
|
||||
<span>{detail.videoInfo.type}</span>
|
||||
)}
|
||||
{detail.type_name && <span>{detail.type_name}</span>}
|
||||
</div>
|
||||
{/* 按钮区域 */}
|
||||
<div className='flex items-center gap-4 mb-4 flex-shrink-0'>
|
||||
@@ -210,7 +198,11 @@ function DetailPageClient() {
|
||||
'source'
|
||||
)}&id=${searchParams.get(
|
||||
'id'
|
||||
)}&title=${encodeURIComponent(detail.videoInfo.title)}`}
|
||||
)}&title=${encodeURIComponent(detail.title)}${
|
||||
detail.year || fallbackYear
|
||||
? `&year=${detail.year || fallbackYear}`
|
||||
: ''
|
||||
}`}
|
||||
className='flex items-center justify-center gap-2 px-6 py-2 bg-green-500 hover:bg-green-600 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>
|
||||
@@ -223,8 +215,12 @@ function DetailPageClient() {
|
||||
)}&id=${searchParams.get(
|
||||
'id'
|
||||
)}&index=1&position=0&title=${encodeURIComponent(
|
||||
detail.videoInfo.title
|
||||
)}`}
|
||||
detail.title
|
||||
)}${
|
||||
detail.year || fallbackYear
|
||||
? `&year=${detail.year || fallbackYear}`
|
||||
: ''
|
||||
}`}
|
||||
className='hidden sm:flex items-center justify-center gap-2 px-6 py-2 bg-gray-500 hover:bg-gray-600 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>
|
||||
@@ -240,8 +236,12 @@ function DetailPageClient() {
|
||||
)}&id=${searchParams.get(
|
||||
'id'
|
||||
)}&index=1&position=0&title=${encodeURIComponent(
|
||||
detail.videoInfo.title
|
||||
)}`}
|
||||
detail.title
|
||||
)}${
|
||||
detail.year || fallbackYear
|
||||
? `&year=${detail.year || fallbackYear}`
|
||||
: ''
|
||||
}`}
|
||||
className='flex items-center justify-center gap-2 px-6 py-2 bg-green-500 hover:bg-green-600 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>
|
||||
@@ -291,18 +291,18 @@ function DetailPageClient() {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{detail.videoInfo.desc && (
|
||||
{detail.desc && (
|
||||
<div
|
||||
className='mt-0 text-base leading-relaxed opacity-90 overflow-y-auto pr-2 flex-1 min-h-0 scrollbar-hide'
|
||||
style={{ whiteSpace: 'pre-line' }}
|
||||
>
|
||||
{detail.videoInfo.desc}
|
||||
{detail.desc}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 选集按钮区 */}
|
||||
{detail.episodes.length > 0 && (
|
||||
{detail.episodes && detail.episodes.length > 0 && (
|
||||
<div className='mt-0 sm:mt-8 bg-transparent rounded-xl p-2 sm:p-6'>
|
||||
<div className='flex items-center gap-2 mb-4'>
|
||||
<div className='text-xl font-semibold'>选集</div>
|
||||
@@ -318,7 +318,11 @@ function DetailPageClient() {
|
||||
'source'
|
||||
)}&id=${searchParams.get('id')}&index=${
|
||||
idx + 1
|
||||
}&title=${encodeURIComponent(detail.videoInfo.title)}`}
|
||||
}&title=${encodeURIComponent(detail.title)}${
|
||||
detail.year || fallbackYear
|
||||
? `&year=${detail.year || fallbackYear}`
|
||||
: ''
|
||||
}`}
|
||||
className='bg-gray-500/80 hover:bg-green-500 text-white px-5 py-2 rounded-lg transition-colors text-base font-medium w-24 text-center'
|
||||
>
|
||||
第{idx + 1}集
|
||||
|
||||
@@ -89,6 +89,7 @@ function HomeClient() {
|
||||
id,
|
||||
source,
|
||||
title: fav.title,
|
||||
year: fav.year,
|
||||
poster: fav.cover,
|
||||
episodes: fav.total_episodes,
|
||||
source_name: fav.source_name,
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
savePlayRecord,
|
||||
toggleFavorite,
|
||||
} from '@/lib/db.client';
|
||||
import { VideoDetail } from '@/lib/types';
|
||||
import { type VideoDetail, fetchVideoDetail } from '@/lib/fetchVideoDetail';
|
||||
|
||||
// 扩展 HTMLVideoElement 类型以支持 hls 属性
|
||||
declare global {
|
||||
@@ -47,9 +47,10 @@ interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
episodes?: number;
|
||||
episodes: string[];
|
||||
source: string;
|
||||
source_name: string;
|
||||
year: string;
|
||||
}
|
||||
|
||||
function PlayPageClient() {
|
||||
@@ -65,6 +66,7 @@ function PlayPageClient() {
|
||||
|
||||
// 初始标题:如果 URL 中携带 title 参数,则优先使用
|
||||
const [videoTitle, setVideoTitle] = useState(searchParams.get('title') || '');
|
||||
const videoYear = searchParams.get('year') || '';
|
||||
const [videoCover, setVideoCover] = useState('');
|
||||
|
||||
const [currentSource, setCurrentSource] = useState(
|
||||
@@ -205,21 +207,20 @@ function PlayPageClient() {
|
||||
|
||||
const fetchDetail = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/detail?source=${currentSource}&id=${currentId}`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error('获取视频详情失败');
|
||||
}
|
||||
const data = await response.json();
|
||||
const detailData = await fetchVideoDetail({
|
||||
source: currentSource,
|
||||
id: currentId,
|
||||
fallbackTitle: videoTitle,
|
||||
fallbackYear: videoYear,
|
||||
});
|
||||
|
||||
// 更新状态保存详情
|
||||
setVideoTitle(data.videoInfo.title || videoTitle);
|
||||
setVideoCover(data.videoInfo.cover);
|
||||
setDetail(data);
|
||||
setVideoTitle(detailData.title || videoTitle);
|
||||
setVideoCover(detailData.poster);
|
||||
setDetail(detailData);
|
||||
|
||||
// 确保集数索引在有效范围内
|
||||
if (currentEpisodeIndex >= data.episodes.length) {
|
||||
if (currentEpisodeIndex >= detailData.episodes.length) {
|
||||
console.log('currentEpisodeIndex', currentEpisodeIndex);
|
||||
setCurrentEpisodeIndex(0);
|
||||
}
|
||||
@@ -474,14 +475,19 @@ function PlayPageClient() {
|
||||
sourceMap.forEach((results) => {
|
||||
if (results.length === 0) return;
|
||||
|
||||
// 优先选择与当前视频标题完全匹配的结果
|
||||
// 只选择和当前视频标题完全匹配的结果,如果有年份,还需要年份完全匹配
|
||||
const exactMatch = results.find(
|
||||
(result) => result.title.toLowerCase() === videoTitle.toLowerCase()
|
||||
(result) =>
|
||||
result.title.toLowerCase() === videoTitle.toLowerCase() &&
|
||||
(videoYear
|
||||
? result.year.toLowerCase() === videoYear.toLowerCase()
|
||||
: true)
|
||||
);
|
||||
|
||||
// 如果没有完全匹配,选择第一个结果
|
||||
const selectedResult = exactMatch || results[0];
|
||||
processedResults.push(selectedResult);
|
||||
if (exactMatch) {
|
||||
processedResults.push(exactMatch);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
setSearchResults(processedResults);
|
||||
@@ -515,13 +521,12 @@ function PlayPageClient() {
|
||||
}
|
||||
|
||||
// 获取新源的详情
|
||||
const response = await fetch(
|
||||
`/api/detail?source=${newSource}&id=${newId}`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error('获取新源详情失败');
|
||||
}
|
||||
const newDetail = await response.json();
|
||||
const newDetail = await fetchVideoDetail({
|
||||
source: newSource,
|
||||
id: newId,
|
||||
fallbackTitle: newTitle,
|
||||
fallbackYear: videoYear,
|
||||
});
|
||||
|
||||
// 尝试跳转到当前正在播放的集数
|
||||
let targetIndex = currentEpisodeIndex;
|
||||
@@ -540,8 +545,8 @@ function PlayPageClient() {
|
||||
// 关闭换源面板
|
||||
setShowSourcePanel(false);
|
||||
|
||||
setVideoTitle(newDetail.videoInfo.title || newTitle);
|
||||
setVideoCover(newDetail.videoInfo.cover);
|
||||
setVideoTitle(newDetail.title || newTitle);
|
||||
setVideoCover(newDetail.poster);
|
||||
setCurrentSource(newSource);
|
||||
setCurrentId(newId);
|
||||
setDetail(newDetail);
|
||||
@@ -687,7 +692,7 @@ function PlayPageClient() {
|
||||
!currentSourceRef.current ||
|
||||
!currentIdRef.current ||
|
||||
!videoTitleRef.current ||
|
||||
!detailRef.current?.videoInfo?.source_name
|
||||
!detailRef.current?.source_name
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -704,8 +709,9 @@ function PlayPageClient() {
|
||||
try {
|
||||
await savePlayRecord(currentSourceRef.current, currentIdRef.current, {
|
||||
title: videoTitleRef.current,
|
||||
source_name: detailRef.current?.videoInfo.source_name,
|
||||
source_name: detailRef.current?.source_name,
|
||||
cover: videoCover,
|
||||
year: detailRef.current?.year || videoYear || '',
|
||||
index: currentEpisodeIndexRef.current + 1, // 转换为1基索引
|
||||
total_episodes: totalEpisodes,
|
||||
play_time: Math.floor(currentTime),
|
||||
@@ -744,7 +750,8 @@ function PlayPageClient() {
|
||||
try {
|
||||
const newState = await toggleFavorite(currentSource, currentId, {
|
||||
title: videoTitle,
|
||||
source_name: detail?.videoInfo.source_name || '',
|
||||
source_name: detail?.source_name || '',
|
||||
year: detail?.year || videoYear || '',
|
||||
cover: videoCover || '',
|
||||
total_episodes: totalEpisodes || 1,
|
||||
save_time: Date.now(),
|
||||
@@ -1253,7 +1260,7 @@ function PlayPageClient() {
|
||||
favorited={favorited}
|
||||
totalEpisodes={totalEpisodes}
|
||||
currentEpisodeIndex={currentEpisodeIndex}
|
||||
sourceName={detail?.videoInfo.source_name || ''}
|
||||
sourceName={detail?.source_name || ''}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
onOpenSourcePanel={handleSourcePanelOpen}
|
||||
isFullscreen={isFullscreen}
|
||||
@@ -1499,7 +1506,7 @@ function PlayPageClient() {
|
||||
{result.episodes && (
|
||||
<div className='absolute top-2 right-2 w-6 h-6 bg-green-500 rounded-full flex items-center justify-center'>
|
||||
<span className='text-white text-xs font-bold'>
|
||||
{result.episodes}
|
||||
{result.episodes.length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -26,7 +26,11 @@ function SearchPageClient() {
|
||||
poster: string;
|
||||
source: string;
|
||||
source_name: string;
|
||||
episodes?: number;
|
||||
episodes: string[];
|
||||
year: string;
|
||||
class?: string;
|
||||
type_name?: string;
|
||||
desc?: string;
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
@@ -40,13 +44,15 @@ function SearchPageClient() {
|
||||
// 视图模式:聚合(agg) 或 全部(all)
|
||||
const [viewMode, setViewMode] = useState<'agg' | 'all'>('agg');
|
||||
|
||||
// 聚合后的结果(按标题分组)
|
||||
// 聚合后的结果(按标题和年份分组)
|
||||
const aggregatedResults = useMemo(() => {
|
||||
const map = new Map<string, SearchResult[]>();
|
||||
searchResults.forEach((item) => {
|
||||
const arr = map.get(item.title) || [];
|
||||
// 使用 title + year 作为键,若 year 不存在则使用 'unknown'
|
||||
const key = `${item.title}-${item.year || 'unknown'}`;
|
||||
const arr = map.get(key) || [];
|
||||
arr.push(item);
|
||||
map.set(item.title, arr);
|
||||
map.set(key, arr);
|
||||
});
|
||||
return Array.from(map.values());
|
||||
}, [searchResults]);
|
||||
@@ -104,6 +110,7 @@ function SearchPageClient() {
|
||||
setIsLoading(true);
|
||||
setShowResults(true);
|
||||
|
||||
router.push(`/search?q=${encodeURIComponent(searchQuery)}`);
|
||||
// 直接发请求
|
||||
fetchSearchResults(searchQuery);
|
||||
|
||||
@@ -165,16 +172,30 @@ function SearchPageClient() {
|
||||
<div className='justify-start grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8 sm:px-4'>
|
||||
{viewMode === 'agg'
|
||||
? aggregatedResults.map((group) => {
|
||||
const key = group[0].title;
|
||||
const key = `${group[0].title}-${
|
||||
group[0].year || 'unknown'
|
||||
}`;
|
||||
return (
|
||||
<div key={key} className='w-full'>
|
||||
<AggregateCard items={group} />
|
||||
<AggregateCard
|
||||
items={group}
|
||||
query={searchQuery}
|
||||
year={group[0].year}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: searchResults.map((item) => (
|
||||
<div key={item.id} className='w-full'>
|
||||
<VideoCard {...item} from='search' />
|
||||
<VideoCard
|
||||
id={item.id}
|
||||
title={item.title}
|
||||
poster={item.poster}
|
||||
episodes={item.episodes.length}
|
||||
source={item.source}
|
||||
source_name={item.source_name}
|
||||
from='search'
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{searchResults.length === 0 && (
|
||||
|
||||
@@ -10,11 +10,13 @@ interface SearchResult {
|
||||
poster: string;
|
||||
source: string;
|
||||
source_name: string;
|
||||
episodes?: number;
|
||||
episodes: string[];
|
||||
}
|
||||
|
||||
interface AggregateCardProps {
|
||||
/** 同一标题下的多个搜索结果 */
|
||||
query?: string;
|
||||
year?: string;
|
||||
items: SearchResult[];
|
||||
}
|
||||
|
||||
@@ -52,14 +54,24 @@ function PlayCircleSolid({
|
||||
* 点击播放按钮 -> 跳到第一个源播放
|
||||
* 点击卡片其他区域 -> 跳到聚合详情页 (/aggregate)
|
||||
*/
|
||||
const AggregateCard: React.FC<AggregateCardProps> = ({ items }) => {
|
||||
const AggregateCard: React.FC<AggregateCardProps> = ({
|
||||
query = '',
|
||||
year = 0,
|
||||
items,
|
||||
}) => {
|
||||
// 使用列表中的第一个结果做展示 & 播放
|
||||
const first = items[0];
|
||||
const [playHover, setPlayHover] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Link href={`/aggregate?q=${encodeURIComponent(first.title)}`}>
|
||||
<Link
|
||||
href={`/aggregate?q=${encodeURIComponent(
|
||||
query
|
||||
)}&title=${encodeURIComponent(first.title)}${
|
||||
year ? `&year=${encodeURIComponent(year)}` : ''
|
||||
}`}
|
||||
>
|
||||
<div className='group relative w-full rounded-lg bg-transparent shadow-none flex flex-col'>
|
||||
{/* 封面图片 2:3 */}
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-md'>
|
||||
@@ -85,7 +97,9 @@ const AggregateCard: React.FC<AggregateCardProps> = ({ items }) => {
|
||||
router.push(
|
||||
`/play?source=${first.source}&id=${
|
||||
first.id
|
||||
}&title=${encodeURIComponent(first.title)}&from=aggregate`
|
||||
}&title=${encodeURIComponent(first.title)}${
|
||||
year ? `&year=${year}` : ''
|
||||
}&from=aggregate`
|
||||
);
|
||||
}}
|
||||
onMouseEnter={() => setPlayHover(true)}
|
||||
|
||||
@@ -101,6 +101,7 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
|
||||
id={id}
|
||||
title={record.title}
|
||||
poster={record.cover}
|
||||
year={record.year}
|
||||
source={source}
|
||||
source_name={record.source_name}
|
||||
progress={getProgress(record)}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface VideoCardProps {
|
||||
episodes?: number;
|
||||
source_name: string;
|
||||
progress?: number;
|
||||
year?: string;
|
||||
from?: string;
|
||||
currentEpisode?: number;
|
||||
onDelete?: () => void;
|
||||
@@ -79,6 +80,7 @@ export default function VideoCard({
|
||||
source,
|
||||
source_name,
|
||||
progress,
|
||||
year,
|
||||
from,
|
||||
currentEpisode,
|
||||
onDelete,
|
||||
@@ -112,6 +114,7 @@ export default function VideoCard({
|
||||
const newState = await toggleFavorite(source, id, {
|
||||
title,
|
||||
source_name,
|
||||
year: year || '',
|
||||
cover: poster,
|
||||
total_episodes: episodes ?? 1,
|
||||
save_time: Date.now(),
|
||||
@@ -147,7 +150,7 @@ export default function VideoCard({
|
||||
<Link
|
||||
href={`/detail?source=${source}&id=${id}&title=${encodeURIComponent(
|
||||
title
|
||||
)}${from ? `&from=${from}` : ''}`}
|
||||
)}${year ? `&year=${year}` : ''}${from ? `&from=${from}` : ''}`}
|
||||
>
|
||||
<div className='group relative w-full rounded-lg bg-transparent shadow-none flex flex-col'>
|
||||
{/* 海报图片 - 2:3 比例 */}
|
||||
@@ -174,7 +177,7 @@ export default function VideoCard({
|
||||
router.push(
|
||||
`/play?source=${source}&id=${id}&title=${encodeURIComponent(
|
||||
title
|
||||
)}`
|
||||
)}${year ? `&year=${year}` : ''}`
|
||||
);
|
||||
}}
|
||||
onMouseEnter={() => setPlayHover(true)}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
export interface PlayRecord {
|
||||
title: string;
|
||||
source_name: string;
|
||||
year: string;
|
||||
cover: string;
|
||||
index: number; // 第几集
|
||||
total_episodes: number; // 总集数
|
||||
@@ -270,6 +271,7 @@ export async function clearSearchHistory(): Promise<void> {
|
||||
export interface Favorite {
|
||||
title: string;
|
||||
source_name: string;
|
||||
year: string;
|
||||
cover: string;
|
||||
total_episodes: number;
|
||||
save_time: number;
|
||||
|
||||
73
src/lib/fetchVideoDetail.ts
Normal file
73
src/lib/fetchVideoDetail.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export interface VideoDetail {
|
||||
id: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
episodes: string[];
|
||||
source: string;
|
||||
source_name: string;
|
||||
class?: string;
|
||||
year: string;
|
||||
desc?: string;
|
||||
type_name?: string;
|
||||
}
|
||||
|
||||
interface FetchVideoDetailOptions {
|
||||
source: string;
|
||||
id: string;
|
||||
fallbackTitle?: string;
|
||||
fallbackYear?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 source 与 id 获取视频详情。
|
||||
* 1. 若传入 fallbackTitle,则先调用 /api/search 搜索精确匹配。
|
||||
* 2. 若搜索未命中或未提供 fallbackTitle,则直接调用 /api/detail。
|
||||
*/
|
||||
export async function fetchVideoDetail({
|
||||
source,
|
||||
id,
|
||||
fallbackTitle = '',
|
||||
fallbackYear = '',
|
||||
}: FetchVideoDetailOptions): Promise<VideoDetail> {
|
||||
// 优先通过搜索接口查找精确匹配
|
||||
if (fallbackTitle) {
|
||||
try {
|
||||
const searchResp = await fetch(
|
||||
`/api/search?q=${encodeURIComponent(fallbackTitle)}`
|
||||
);
|
||||
if (searchResp.ok) {
|
||||
const searchData = await searchResp.json();
|
||||
const exactMatch = searchData.results.find(
|
||||
(item: VideoDetail) =>
|
||||
item.source.toString() === source.toString() &&
|
||||
item.id.toString() === id.toString()
|
||||
);
|
||||
if (exactMatch) {
|
||||
return exactMatch as VideoDetail;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
// 调用 /api/detail 接口
|
||||
const response = await fetch(`/api/detail?source=${source}&id=${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('获取详情失败');
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
id: data?.videoInfo?.id || id,
|
||||
title: data?.videoInfo?.title || fallbackTitle,
|
||||
poster: data?.videoInfo?.cover || '',
|
||||
episodes: data?.episodes || [],
|
||||
source: data?.videoInfo?.source || source,
|
||||
source_name: data?.videoInfo?.source_name || '',
|
||||
class: data?.videoInfo?.remarks || '',
|
||||
year: data?.videoInfo?.year || fallbackYear || '',
|
||||
desc: data?.videoInfo?.desc || '',
|
||||
type_name: data?.videoInfo?.type || '',
|
||||
} as VideoDetail;
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import clsx, { ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/** Merge classes with tailwind-merge with clsx full feature */
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
export function cleanHtmlTags(text: string): string {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/<[^>]+>/g, '\n') // 将 HTML 标签替换为换行
|
||||
.replace(/\n+/g, '\n') // 将多个连续换行合并为一个
|
||||
.replace(/[ \t]+/g, ' ') // 将多个连续空格和制表符合并为一个空格,但保留换行符
|
||||
.replace(/^\n+|\n+$/g, '') // 去掉首尾换行
|
||||
.replace(/ /g, ' ') // 将 替换为空格
|
||||
.trim(); // 去掉首尾空格
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user