diff --git a/src/app/api/detail/route.ts b/src/app/api/detail/route.ts index bb6b515..6c90ce6 100644 --- a/src/app/api/detail/route.ts +++ b/src/app/api/detail/route.ts @@ -1,7 +1,198 @@ import { NextResponse } from 'next/server'; -import { getCacheTime } from '@/lib/config'; -import { getVideoDetail } from '@/lib/video'; +import { API_CONFIG, ApiSite, getApiSites, getCacheTime } from '@/lib/config'; +import { VideoDetail } from '@/lib/types'; + +// 匹配 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 +): 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, + 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); +} export async function GET(request: Request) { const { searchParams } = new URL(request.url); diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 44a429a..1bf117a 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from 'next/server'; import { API_CONFIG, ApiSite, getApiSites, getCacheTime } from '@/lib/config'; -import { getVideoDetail } from '@/lib/video'; export interface SearchResult { id: string; @@ -17,6 +16,7 @@ interface ApiSearchItem { vod_name: string; vod_pic: string; vod_remarks?: string; + vod_play_url?: string; } async function searchFromApi( @@ -56,14 +56,25 @@ async function searchFromApi( } // 处理第一页结果 - const results = data.list.map((item: ApiSearchItem) => ({ - id: item.vod_id, - title: item.vod_name, - poster: item.vod_pic, - episodes: item.vod_remarks ? parseInt(item.vod_remarks) : undefined, - source: apiSite.key, - source_name: apiName, - })); + const results = data.list.map((item: ApiSearchItem) => { + let episodes: number | undefined = undefined; + + // 使用正则表达式从 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; + } + + return { + id: item.vod_id, + title: item.vod_name, + poster: item.vod_pic, + episodes, + source: apiSite.key, + source_name: apiName, + }; + }); // 获取总页数 const pageCount = data.pagecount || 1; @@ -106,16 +117,25 @@ async function searchFromApi( if (!pageData || !pageData.list || !Array.isArray(pageData.list)) return []; - return pageData.list.map((item: ApiSearchItem) => ({ - id: item.vod_id, - title: item.vod_name, - poster: item.vod_pic, - episodes: item.vod_remarks - ? parseInt(item.vod_remarks) - : undefined, - source: apiSite.key, - source_name: apiName, - })); + return pageData.list.map((item: ApiSearchItem) => { + let episodes: number | undefined = undefined; + + // 使用正则表达式从 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; + } + + return { + id: item.vod_id, + title: item.vod_name, + poster: item.vod_pic, + episodes, + source: apiSite.key, + source_name: apiName, + }; + }); } catch (error) { return []; } @@ -135,20 +155,7 @@ async function searchFromApi( }); } - // 获取每个结果的详情 - const detailPromises = results.map(async (result: SearchResult) => { - try { - const detail = await getVideoDetail(result.id, result.source); - if (detail.episodes.length > 1) { - return { ...result, episodes: detail.episodes.length }; - } - return result; - } catch (error) { - return result; - } - }); - - return await Promise.all(detailPromises); + return results; } catch (error) { return []; } diff --git a/src/app/detail/page.tsx b/src/app/detail/page.tsx index e8a161a..0288e46 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/video'; +import { VideoDetail } from '@/lib/types'; import PageLayout from '@/components/PageLayout'; diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 90ab9ee..8841c51 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -16,7 +16,7 @@ import { savePlayRecord, toggleFavorite, } from '@/lib/db.client'; -import { VideoDetail } from '@/lib/video'; +import { VideoDetail } from '@/lib/types'; // 扩展 HTMLVideoElement 类型以支持 hls 属性 declare global { @@ -491,28 +491,6 @@ function PlayPageClient() { }, // 控制栏配置 controls: [ - { - position: 'left', - index: 10, - html: '', - tooltip: '后退10秒', - click: function () { - if (artPlayerRef.current) { - artPlayerRef.current.backward = 10; - } - }, - }, - { - position: 'left', - index: 12, - html: '', - tooltip: '前进10秒', - click: function () { - if (artPlayerRef.current) { - artPlayerRef.current.forward = 10; - } - }, - }, { position: 'left', index: 13, diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 0e19c1d..db96280 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -99,7 +99,7 @@ function SearchPageClient() { return ( -
+
{/* 搜索框 */}
diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..8c640fb --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,19 @@ +export interface VideoDetail { + code: number; + episodes: string[]; + detailUrl: string; + videoInfo: { + title: string; + cover?: string; + desc?: string; + type?: string; + year?: string; + area?: string; + director?: string; + actor?: string; + remarks?: string; + source_name: string; + source: string; + id: string; + }; +} diff --git a/src/lib/video.ts b/src/lib/video.ts deleted file mode 100644 index 4d9193b..0000000 --- a/src/lib/video.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { API_CONFIG, ApiSite, getApiSites } from '@/lib/config'; - -// 匹配 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(); // 去掉首尾空格 -} - -export interface VideoDetail { - code: number; - episodes: string[]; - detailUrl: string; - videoInfo: { - title: string; - cover?: string; - desc?: string; - type?: string; - year?: string; - area?: string; - director?: string; - actor?: string; - remarks?: string; - source_name: string; - source: string; - id: string; - }; -} - -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, - area: videoDetail.vod_area, - director: videoDetail.vod_director, - actor: videoDetail.vod_actor, - remarks: videoDetail.vod_remarks, - source_name: apiSite.name, - source: apiSite.key, - id, - }, - }; -} - -// 对外导出统一的获取详情方法 -export 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); -}