diff --git a/src/app/api/detail/route.ts b/src/app/api/detail/route.ts index 0668dbb..2e55f9f 100644 --- a/src/app/api/detail/route.ts +++ b/src/app/api/detail/route.ts @@ -1,189 +1,7 @@ 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; - -async function handleSpecialSourceDetail( - id: string, - apiSite: ApiSite -): Promise { - 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)).map((link: string) => { - link = link.substring(1); // 去掉开头的 $ - const parenIndex = link.indexOf('('); - return parenIndex > 0 ? link.substring(0, parenIndex) : link; - }); - - // 提取标题 - const titleMatch = html.match(/]*>([^<]+)<\/h1>/); - const titleText = titleMatch ? titleMatch[1].trim() : ''; - - // 提取描述 - const descMatch = html.match( - /]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/ - ); - const descText = descMatch ? cleanHtmlTags(descMatch[1]) : ''; - - // 提取封面 - const coverMatch = html.match(/(https?:\/\/[^"'\s]+?\.jpg)/g); - const coverUrl = coverMatch ? coverMatch[0].trim() : ''; - - return { - code: 200, - episodes: matches, - detailUrl, - videoInfo: { - title: titleText, - cover: coverUrl, - desc: descText, - source_name: apiSite.name, - source: apiSite.key, - id, - }, - }; -} - -async function getDetailFromApi( - apiSite: ApiSite, - id: string -): Promise { - 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://')) - ); - } - } - - // 如果播放源为空,则尝试从内容中解析 m3u8 - 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, - detailUrl, - videoInfo: { - title: videoDetail.vod_name, - cover: videoDetail.vod_pic, - desc: cleanHtmlTags(videoDetail.vod_content), - type: videoDetail.type_name, - year: videoDetail.vod_year - ? videoDetail.vod_year.match(/\d{4}/)?.[0] || '' - : '', - area: videoDetail.vod_area, - director: videoDetail.vod_director, - actor: videoDetail.vod_actor, - remarks: videoDetail.vod_remarks, - source_name: apiSite.name, - source: apiSite.key, - id, - }, - }; -} - -// 获取视频详情的主要方法 -async function getVideoDetail( - id: string, - sourceCode: string -): Promise { - 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 handleSpecialSourceDetail(id, apiSite); - } - - return getDetailFromApi(apiSite, id); -} +import { getApiSites, getCacheTime } from '@/lib/config'; +import { getDetailFromApi } from '@/lib/downstream'; export const runtime = 'edge'; @@ -196,8 +14,19 @@ export async function GET(request: Request) { return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }); } + if (!/^[\w-]+$/.test(id)) { + return NextResponse.json({ error: '无效的视频ID格式' }, { status: 400 }); + } + try { - const result = await getVideoDetail(id, sourceCode); + const apiSites = getApiSites(); + const apiSite = apiSites.find((site) => site.key === sourceCode); + + if (!apiSite) { + return NextResponse.json({ error: '无效的API来源' }, { status: 400 }); + } + + const result = await getDetailFromApi(apiSite, sourceCode); const cacheTime = getCacheTime(); return NextResponse.json(result, { diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 0e948f5..9f0e7f0 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -1,195 +1,10 @@ import { NextResponse } from 'next/server'; -import { API_CONFIG, ApiSite, getApiSites, getCacheTime } from '@/lib/config'; -import { SearchResult } from '@/lib/types'; -import { cleanHtmlTags } from '@/lib/utils'; +import { getApiSites, getCacheTime } from '@/lib/config'; +import { searchFromApi } from '@/lib/downstream'; export const runtime = 'edge'; -// 根据环境变量决定最大搜索页数,默认 5 -const MAX_SEARCH_PAGES: number = - Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5; - -interface ApiSearchItem { - vod_id: string; - vod_name: string; - vod_pic: string; - vod_remarks?: string; - vod_play_url?: string; - vod_class?: string; - vod_year?: string; - vod_content?: string; - vod_douban_id?: number; - type_name?: string; -} - -async function searchFromApi( - apiSite: ApiSite, - query: string -): Promise { - 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) => { - let episodes: string[] = []; - - // 使用正则表达式从 vod_play_url 提取 m3u8 链接 - if (item.vod_play_url) { - const m3u8Regex = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g; - // 先用 $$$ 分割 - 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.trim().replace(/\s+/g, ' '), - poster: item.vod_pic, - episodes, - source: apiSite.key, - source_name: apiName, - class: item.vod_class, - year: item.vod_year ? item.vod_year.match(/\d{4}/)?.[0] || '' : '', - desc: cleanHtmlTags(item.vod_content || ''), - type_name: item.type_name, - douban_id: item.vod_douban_id, - }; - }); - - // 获取总页数 - const pageCount = data.pagecount || 1; - // 确定需要获取的额外页数 - const pagesToFetch = Math.min(pageCount - 1, MAX_SEARCH_PAGES - 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) => { - let episodes: string[] = []; - - // 使用正则表达式从 vod_play_url 提取 m3u8 链接 - if (item.vod_play_url) { - const m3u8Regex = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g; - 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.trim().replace(/\s+/g, ' '), - poster: item.vod_pic, - episodes, - source: apiSite.key, - source_name: apiName, - class: item.vod_class, - year: item.vod_year - ? item.vod_year.match(/\d{4}/)?.[0] || '' - : '', - desc: cleanHtmlTags(item.vod_content || ''), - type_name: item.type_name, - douban_id: item.vod_douban_id, - }; - }); - } catch (error) { - return []; - } - })(); - - additionalPagePromises.push(pagePromise); - } - - // 等待所有额外页的结果 - const additionalResults = await Promise.all(additionalPagePromises); - - // 合并所有页的结果 - additionalResults.forEach((pageResults) => { - if (pageResults.length > 0) { - results.push(...pageResults); - } - }); - } - - return results; - } catch (error) { - return []; - } -} - export async function GET(request: Request) { const { searchParams } = new URL(request.url); const query = searchParams.get('q'); diff --git a/src/app/detail/page.tsx b/src/app/detail/page.tsx index 17f98af..4b856ee 100644 --- a/src/app/detail/page.tsx +++ b/src/app/detail/page.tsx @@ -14,7 +14,10 @@ import { isFavorited, toggleFavorite, } from '@/lib/db.client'; -import { type VideoDetail, fetchVideoDetail } from '@/lib/fetchVideoDetail'; +import { + type VideoDetail, + fetchVideoDetail, +} from '@/lib/fetchVideoDetail.client'; import PageLayout from '@/components/PageLayout'; diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index a9c57b5..668ba6c 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -41,7 +41,10 @@ import { savePlayRecord, toggleFavorite, } from '@/lib/db.client'; -import { type VideoDetail, fetchVideoDetail } from '@/lib/fetchVideoDetail'; +import { + type VideoDetail, + fetchVideoDetail, +} from '@/lib/fetchVideoDetail.client'; import { SearchResult } from '@/lib/types'; // 扩展 HTMLVideoElement 类型以支持 hls 属性 diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index e1d0df9..1449f49 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -48,7 +48,13 @@ function SearchPageClient() { arr.push(item); map.set(key, arr); }); - return map; + return Array.from(map.entries()).sort((a, b) => { + return a[1][0].year === b[1][0].year + ? a[0].localeCompare(b[0]) + : a[1][0].year > b[1][0].year + ? -1 + : 1; + }); }, [searchResults]); useEffect(() => { @@ -81,7 +87,15 @@ function SearchPageClient() { `/api/search?q=${encodeURIComponent(query.trim())}` ); const data = await response.json(); - setSearchResults(data.results); + setSearchResults( + data.results.sort((a: SearchResult, b: SearchResult) => { + return a.year === b.year + ? a.title.localeCompare(b.title) + : a.year > b.year + ? -1 + : 1; + }) + ); setShowResults(true); } catch (error) { setSearchResults([]); @@ -168,19 +182,17 @@ function SearchPageClient() { 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' > {viewMode === 'agg' - ? Array.from(aggregatedResults.entries()).map( - ([mapKey, group]) => { - return ( -
- -
- ); - } - ) + ? aggregatedResults.map(([mapKey, group]) => { + return ( +
+ +
+ ); + }) : searchResults.map((item) => (
{ + 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) => { + let episodes: string[] = []; + + // 使用正则表达式从 vod_play_url 提取 m3u8 链接 + if (item.vod_play_url) { + const m3u8Regex = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g; + // 先用 $$$ 分割 + 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.trim().replace(/\s+/g, ' '), + poster: item.vod_pic, + episodes, + source: apiSite.key, + source_name: apiName, + class: item.vod_class, + year: item.vod_year ? item.vod_year.match(/\d{4}/)?.[0] || '' : '', + desc: cleanHtmlTags(item.vod_content || ''), + type_name: item.type_name, + douban_id: item.vod_douban_id, + }; + }); + + // 获取总页数 + const pageCount = data.pagecount || 1; + // 确定需要获取的额外页数 + const pagesToFetch = Math.min(pageCount - 1, MAX_SEARCH_PAGES - 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) => { + let episodes: string[] = []; + + // 使用正则表达式从 vod_play_url 提取 m3u8 链接 + if (item.vod_play_url) { + const m3u8Regex = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g; + 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.trim().replace(/\s+/g, ' '), + poster: item.vod_pic, + episodes, + source: apiSite.key, + source_name: apiName, + class: item.vod_class, + year: item.vod_year + ? item.vod_year.match(/\d{4}/)?.[0] || '' + : '', + desc: cleanHtmlTags(item.vod_content || ''), + type_name: item.type_name, + douban_id: item.vod_douban_id, + }; + }); + } catch (error) { + return []; + } + })(); + + additionalPagePromises.push(pagePromise); + } + + // 等待所有额外页的结果 + const additionalResults = await Promise.all(additionalPagePromises); + + // 合并所有页的结果 + additionalResults.forEach((pageResults) => { + if (pageResults.length > 0) { + results.push(...pageResults); + } + }); + } + + return results; + } catch (error) { + return []; + } +} + +// 匹配 m3u8 链接的正则 +const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g; + +export async function getDetailFromApi( + apiSite: ApiSite, + id: string +): Promise { + if (apiSite.detail) { + return handleSpecialSourceDetail(id, apiSite); + } + + 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://')) + ); + } + } + + // 如果播放源为空,则尝试从内容中解析 m3u8 + 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, + detailUrl, + videoInfo: { + title: videoDetail.vod_name, + cover: videoDetail.vod_pic, + desc: cleanHtmlTags(videoDetail.vod_content), + type: videoDetail.type_name, + year: videoDetail.vod_year + ? videoDetail.vod_year.match(/\d{4}/)?.[0] || '' + : '', + area: videoDetail.vod_area, + director: videoDetail.vod_director, + actor: videoDetail.vod_actor, + remarks: videoDetail.vod_remarks, + source_name: apiSite.name, + source: apiSite.key, + id, + }, + }; +} + +async function handleSpecialSourceDetail( + id: string, + apiSite: ApiSite +): Promise { + 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)).map((link: string) => { + link = link.substring(1); // 去掉开头的 $ + const parenIndex = link.indexOf('('); + return parenIndex > 0 ? link.substring(0, parenIndex) : link; + }); + + // 提取标题 + const titleMatch = html.match(/]*>([^<]+)<\/h1>/); + const titleText = titleMatch ? titleMatch[1].trim() : ''; + + // 提取描述 + const descMatch = html.match( + /]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/ + ); + const descText = descMatch ? cleanHtmlTags(descMatch[1]) : ''; + + // 提取封面 + const coverMatch = html.match(/(https?:\/\/[^"'\s]+?\.jpg)/g); + const coverUrl = coverMatch ? coverMatch[0].trim() : ''; + + return { + code: 200, + episodes: matches, + detailUrl, + videoInfo: { + title: titleText, + cover: coverUrl, + desc: descText, + source_name: apiSite.name, + source: apiSite.key, + id, + }, + }; +} diff --git a/src/lib/fetchVideoDetail.client.ts b/src/lib/fetchVideoDetail.client.ts new file mode 100644 index 0000000..1231ed2 --- /dev/null +++ b/src/lib/fetchVideoDetail.client.ts @@ -0,0 +1,74 @@ +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; + douban_id?: number; +} + +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.trim())}` + ); + 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.trim() || fallbackTitle.trim(), + 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/fetchVideoDetail.ts b/src/lib/fetchVideoDetail.ts index 1231ed2..d3131ae 100644 --- a/src/lib/fetchVideoDetail.ts +++ b/src/lib/fetchVideoDetail.ts @@ -1,3 +1,8 @@ +import { getApiSites } from '@/lib/config'; +import { SearchResult } from '@/lib/types'; + +import { getDetailFromApi, searchFromApi } from './downstream'; + export interface VideoDetail { id: string; title: string; @@ -16,7 +21,6 @@ interface FetchVideoDetailOptions { source: string; id: string; fallbackTitle?: string; - fallbackYear?: string; } /** @@ -28,24 +32,35 @@ export async function fetchVideoDetail({ source, id, fallbackTitle = '', - fallbackYear = '', }: FetchVideoDetailOptions): Promise { // 优先通过搜索接口查找精确匹配 + const apiSites = getApiSites(); + const apiSite = apiSites.find((site) => site.key === source); + if (!apiSite) { + throw new Error('无效的API来源'); + } if (fallbackTitle) { try { - const searchResp = await fetch( - `/api/search?q=${encodeURIComponent(fallbackTitle.trim())}` + const searchData = await searchFromApi(apiSite, fallbackTitle.trim()); + const exactMatch = searchData.find( + (item: SearchResult) => + item.source.toString() === source.toString() && + item.id.toString() === id.toString() ); - 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; - } + if (exactMatch) { + return { + id: exactMatch.id, + title: exactMatch.title, + poster: exactMatch.poster, + episodes: exactMatch.episodes, + source: exactMatch.source, + source_name: exactMatch.source_name, + class: exactMatch.class, + year: exactMatch.year, + desc: exactMatch.desc, + type_name: exactMatch.type_name, + douban_id: exactMatch.douban_id, + } as VideoDetail; } } catch (error) { // do nothing @@ -53,22 +68,18 @@ export async function fetchVideoDetail({ } // 调用 /api/detail 接口 - const response = await fetch(`/api/detail?source=${source}&id=${id}`); - if (!response.ok) { - throw new Error('获取详情失败'); - } - const data = await response.json(); + const detail = await getDetailFromApi(apiSite, id); return { - id: data?.videoInfo?.id || id, - title: data?.videoInfo?.title.trim() || fallbackTitle.trim(), - 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; + id: detail.videoInfo.id, + title: detail.videoInfo.title, + poster: detail.videoInfo.cover || '', + episodes: detail.episodes, + source: detail.videoInfo.source, + source_name: detail.videoInfo.source_name, + class: detail.videoInfo.remarks, + year: detail.videoInfo.year || '', + desc: detail.videoInfo.desc, + type_name: detail.videoInfo.type, + }; } diff --git a/src/lib/refreshRecordAndFavorites.ts b/src/lib/refreshRecordAndFavorites.ts index 9a01e6a..d183875 100644 --- a/src/lib/refreshRecordAndFavorites.ts +++ b/src/lib/refreshRecordAndFavorites.ts @@ -28,7 +28,6 @@ async function refreshRecordAndFavorites() { source, id, fallbackTitle: fallbackTitle.trim(), - fallbackYear: '', }); detailCache.set(key, promise); }