feat: refactor code, sort search result

This commit is contained in:
shinya
2025-07-04 13:35:46 +08:00
parent 4988ac7207
commit faf5340b4b
9 changed files with 511 additions and 420 deletions

View File

@@ -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<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)).map((link: string) => {
link = link.substring(1); // 去掉开头的 $
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 ? 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<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://'))
);
}
}
// 如果播放源为空,则尝试从内容中解析 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<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 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, {

View File

@@ -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<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) => {
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');

View File

@@ -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';

View File

@@ -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 属性

View File

@@ -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 (
<div key={`agg-${mapKey}`} className='w-full'>
<AggregateCard
items={group}
query={searchQuery}
year={group[0].year}
/>
</div>
);
}
)
? aggregatedResults.map(([mapKey, group]) => {
return (
<div key={`agg-${mapKey}`} className='w-full'>
<AggregateCard
items={group}
query={searchQuery}
year={group[0].year}
/>
</div>
);
})
: searchResults.map((item) => (
<div
key={`all-${item.source}-${item.id}`}

345
src/lib/downstream.ts Normal file
View File

@@ -0,0 +1,345 @@
import { API_CONFIG, ApiSite } from '@/lib/config';
import { SearchResult, VideoDetail } from '@/lib/types';
import { cleanHtmlTags } from '@/lib/utils';
// 根据环境变量决定最大搜索页数,默认 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;
}
export 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) => {
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<VideoDetail> {
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<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)).map((link: string) => {
link = link.substring(1); // 去掉开头的 $
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 ? 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,
},
};
}

View File

@@ -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<VideoDetail> {
// 优先通过搜索接口查找精确匹配
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;
}

View File

@@ -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<VideoDetail> {
// 优先通过搜索接口查找精确匹配
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,
};
}

View File

@@ -28,7 +28,6 @@ async function refreshRecordAndFavorites() {
source,
id,
fallbackTitle: fallbackTitle.trim(),
fallbackYear: '',
});
detailCache.set(key, promise);
}