mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-22 02:24:44 +08:00
feat: refactor code, sort search result
This commit is contained in:
@@ -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, {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 属性
|
||||
|
||||
@@ -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
345
src/lib/downstream.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
74
src/lib/fetchVideoDetail.client.ts
Normal file
74
src/lib/fetchVideoDetail.client.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ async function refreshRecordAndFavorites() {
|
||||
source,
|
||||
id,
|
||||
fallbackTitle: fallbackTitle.trim(),
|
||||
fallbackYear: '',
|
||||
});
|
||||
detailCache.set(key, promise);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user