diff --git a/config.json b/config.json index 24d5e68..617980c 100644 --- a/config.json +++ b/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资源" diff --git a/src/app/aggregate/page.tsx b/src/app/aggregate/page.tsx index dec9c5e..ff6f045 100644 --- a/src/app/aggregate/page.tsx +++ b/src/app/aggregate/page.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [details, setDetails] = useState([]); - 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(); + 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 ( @@ -133,10 +125,6 @@ function AggregatePageClient() {
{error}
- ) : !infoReady && detailLoading ? ( -
-
-
) : !infoReady ? (
@@ -219,15 +207,15 @@ function AggregatePageClient() {
{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 ( {/* 名称 */} diff --git a/src/app/api/detail/route.ts b/src/app/api/detail/route.ts index 6c90ce6..9653b07 100644 --- a/src/app/api/detail/route.ts +++ b/src/app/api/detail/route.ts @@ -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 diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 1bf117a..5f70de2 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -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) { diff --git a/src/app/detail/page.tsx b/src/app/detail/page.tsx index 994f8cf..f344613 100644 --- a/src/app/detail/page.tsx +++ b/src/app/detail/page.tsx @@ -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(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() { {/* 返回按钮放置在主信息区左上角 */}
{/* 选集按钮区 */} - {detail.episodes.length > 0 && ( + {detail.episodes && detail.episodes.length > 0 && (
选集
@@ -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}集 diff --git a/src/app/page.tsx b/src/app/page.tsx index c105bd3..da49b07 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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, diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 4d60c22..be50264 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -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 && (
- {result.episodes} + {result.episodes.length}
)} diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index e332275..a171563 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -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(); 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() {
{viewMode === 'agg' ? aggregatedResults.map((group) => { - const key = group[0].title; + const key = `${group[0].title}-${ + group[0].year || 'unknown' + }`; return (
- +
); }) : searchResults.map((item) => (
- +
))} {searchResults.length === 0 && ( diff --git a/src/components/AggregateCard.tsx b/src/components/AggregateCard.tsx index 9230fd0..2888828 100644 --- a/src/components/AggregateCard.tsx +++ b/src/components/AggregateCard.tsx @@ -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 = ({ items }) => { +const AggregateCard: React.FC = ({ + query = '', + year = 0, + items, +}) => { // 使用列表中的第一个结果做展示 & 播放 const first = items[0]; const [playHover, setPlayHover] = useState(false); const router = useRouter(); return ( - +
{/* 封面图片 2:3 */}
@@ -85,7 +97,9 @@ const AggregateCard: React.FC = ({ 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)} diff --git a/src/components/ContinueWatching.tsx b/src/components/ContinueWatching.tsx index 1c06a22..03a4d00 100644 --- a/src/components/ContinueWatching.tsx +++ b/src/components/ContinueWatching.tsx @@ -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)} diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index 836d415..606f46f 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -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({
{/* 海报图片 - 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)} diff --git a/src/lib/db.client.ts b/src/lib/db.client.ts index 306809a..f3b212c 100644 --- a/src/lib/db.client.ts +++ b/src/lib/db.client.ts @@ -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 { export interface Favorite { title: string; source_name: string; + year: string; cover: string; total_episodes: number; save_time: number; diff --git a/src/lib/fetchVideoDetail.ts b/src/lib/fetchVideoDetail.ts new file mode 100644 index 0000000..a5d6fab --- /dev/null +++ b/src/lib/fetchVideoDetail.ts @@ -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 { + // 优先通过搜索接口查找精确匹配 + 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; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 97ccfe9..dafde19 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -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(); // 去掉首尾空格 }