diff --git a/src/app/api/openlist/play/route.ts b/src/app/api/openlist/play/route.ts index a9ef8dd..0ce4d50 100644 --- a/src/app/api/openlist/play/route.ts +++ b/src/app/api/openlist/play/route.ts @@ -124,6 +124,7 @@ export async function GET(request: NextRequest) { name: task.template_id, url: task.url, })) + .filter((quality: any) => quality.url && quality.url.trim() !== '') // 过滤空URL .sort((a: any, b: any) => (qualityOrder[a.name] || 999) - (qualityOrder[b.name] || 999)); if (qualities.length === 0) { @@ -161,9 +162,20 @@ export async function GET(request: NextRequest) { // 如果指定了 format=json,使用 getFinalUrl 并返回 JSON if (format === 'json') { const finalUrl = await getFinalUrl(fileResponse.data.raw_url); + + // 检查URL是否为空 + if (!finalUrl || finalUrl.trim() === '') { + throw new Error('获取到的播放链接为空'); + } + return NextResponse.json({ url: finalUrl }); } + // 检查URL是否为空 + if (!fileResponse.data.raw_url || fileResponse.data.raw_url.trim() === '') { + throw new Error('获取到的播放链接为空'); + } + // 默认返回重定向(用于 tvbox) return NextResponse.redirect(fileResponse.data.raw_url); } diff --git a/src/app/api/xiaoya/play/route.ts b/src/app/api/xiaoya/play/route.ts index ee5cb02..d1bb9c9 100644 --- a/src/app/api/xiaoya/play/route.ts +++ b/src/app/api/xiaoya/play/route.ts @@ -146,6 +146,7 @@ export async function GET(request: NextRequest) { name: task.template_id, url: task.url, })) + .filter((quality: any) => quality.url && quality.url.trim() !== '') // 过滤空URL .sort((a: any, b: any) => (qualityOrder[a.name] || 999) - (qualityOrder[b.name] || 999)); if (qualities.length === 0) { @@ -171,9 +172,20 @@ export async function GET(request: NextRequest) { // 如果指定了 format=json,使用 getFinalUrl 并返回 JSON if (format === 'json') { const finalUrl = await getFinalUrl(playUrl); + + // 检查URL是否为空 + if (!finalUrl || finalUrl.trim() === '') { + throw new Error('获取到的播放链接为空'); + } + return NextResponse.json({ url: finalUrl }); } + // 检查URL是否为空 + if (!playUrl || playUrl.trim() === '') { + throw new Error('获取到的播放链接为空'); + } + // 默认返回重定向(用于 tvbox) return NextResponse.redirect(playUrl); } diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index b103b79..c2189c8 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -168,6 +168,13 @@ function PlayPageClient() { }; }, []); + // 组件卸载时清理定时器 + useEffect(() => { + return () => { + clearRefreshTimer(); + }; + }, []); + // 跳过片头片尾配置 const [skipConfig, setSkipConfig] = useState<{ enable: boolean; @@ -1116,6 +1123,13 @@ function PlayPageClient() { // 视频清晰度列表 const [videoQualities, setVideoQualities] = useState>([]); + // Xiaoya链接刷新相关状态 + const [isRefreshingUrl, setIsRefreshingUrl] = useState(false); // 是否正在刷新链接 + const retryCountRef = useRef(0); // 重试计数 + const lastRefreshTimeRef = useRef(0); // 上次刷新时间 + const refreshTimerRef = useRef(null); // 14分钟定时器 + const currentXiaoyaUrlRef = useRef(''); // 当前xiaoya原始URL(用于刷新) + // 视频源代理模式状态 const [sourceProxyMode, setSourceProxyMode] = useState(false); @@ -1469,11 +1483,235 @@ function PlayPageClient() { return false; }; + /** + * 刷新xiaoya链接(静默刷新,不改变videoUrl状态) + * @param hls HLS实例 + * @param video 视频元素 + * @param isScheduled 是否为定时刷新(true=定时,false=错误触发) + */ + const refreshXiaoyaUrl = async ( + hls: any, + video: HTMLVideoElement, + isScheduled: boolean = false + ) => { + // 防抖:距离上次刷新不足3秒则不刷新 + const now = Date.now(); + if (now - lastRefreshTimeRef.current < 3000) { + console.log('[链接刷新] 防抖跳过'); + return false; + } + + // 重试次数限制(仅对错误触发的刷新) + if (!isScheduled && retryCountRef.current >= 3) { + console.error('[链接刷新] 重试次数已达上限'); + setVideoError('链接已过期且刷新失败,请手动刷新页面'); + hls.destroy(); + return false; + } + + // 检查是否有原始URL + if (!currentXiaoyaUrlRef.current) { + console.warn('[链接刷新] 无原始URL,跳过刷新'); + return false; + } + + console.log(`[链接刷新] 开始刷新 (${isScheduled ? '定时' : '错误触发'})`); + setIsRefreshingUrl(true); + + if (!isScheduled) { + retryCountRef.current++; + } + lastRefreshTimeRef.current = now; + + try { + // 保存当前播放进度 + const currentTime = video.currentTime; + const isPaused = video.paused; + + console.log(`[链接刷新] 开始刷新 (${isScheduled ? '定时' : '错误触发'}), 当前时间:`, currentTime); + + // 重新获取播放链接(添加时间戳避免缓存) + const separator = currentXiaoyaUrlRef.current.includes('?') ? '&' : '?'; + const fetchUrl = `${currentXiaoyaUrlRef.current}${separator}format=json&t=${Date.now()}`; + + const response = await fetch(fetchUrl); + const data = await response.json(); + + if (!data.url) { + throw new Error('未获取到有效链接'); + } + + console.log('[链接刷新] 获取到新链接'); + + // 先停止HLS加载 + hls.stopLoad(); + + // 使用HLS的loadSource方法直接加载新链接(不改变videoUrl状态) + hls.loadSource(data.url); + + // 监听加载完成事件,恢复播放进度 + const onManifestParsed = () => { + // 从指定位置开始加载 + hls.startLoad(currentTime); + + // 等待视频可以seek + const onLoadedData = () => { + video.removeEventListener('loadeddata', onLoadedData); + + // 设置播放位置 + if (currentTime > 0) { + video.currentTime = currentTime; + + // 等待seek完成 + const onSeeked = () => { + console.log('[链接刷新] 刷新完成,恢复到:', video.currentTime); + + video.removeEventListener('seeked', onSeeked); + + // 恢复播放状态 + if (!isPaused) { + video.play().catch(err => { + console.warn('[链接刷新] 自动播放失败:', err); + }); + } + + setIsRefreshingUrl(false); + + // 显示提示 + if (artPlayerRef.current) { + artPlayerRef.current.notice.show = isScheduled + ? '链接已自动刷新' + : '链接已过期并自动刷新'; + } + + // 刷新成功,重置重试计数 + retryCountRef.current = 0; + + // 重新启动14分钟定时器 + startRefreshTimer(hls, video); + }; + + video.addEventListener('seeked', onSeeked, { once: true }); + } else { + // 如果是从头开始,直接播放 + if (!isPaused) { + video.play().catch(err => { + console.warn('[链接刷新] 自动播放失败:', err); + }); + } + + setIsRefreshingUrl(false); + + if (artPlayerRef.current) { + artPlayerRef.current.notice.show = isScheduled + ? '链接已自动刷新' + : '链接已过期并自动刷新'; + } + + retryCountRef.current = 0; + startRefreshTimer(hls, video); + } + }; + + video.addEventListener('loadeddata', onLoadedData, { once: true }); + }; + + // 使用 hls.constructor.Events 访问事件常量 + const HlsEvents = (hls.constructor as any).Events; + hls.once(HlsEvents.MANIFEST_PARSED, onManifestParsed); + + // 添加超时保护(10秒内未加载完成则认为失败) + setTimeout(() => { + hls.off(HlsEvents.MANIFEST_PARSED, onManifestParsed); + if (isRefreshingUrl) { + console.error('[链接刷新] 加载超时'); + setIsRefreshingUrl(false); + throw new Error('加载超时'); + } + }, 10000); + + return true; + } catch (error) { + console.error('[链接刷新] 刷新失败:', error); + setIsRefreshingUrl(false); + + // 如果是定时刷新失败,不显示错误(继续使用旧链接) + if (isScheduled) { + console.warn('[链接刷新] 定时刷新失败,继续使用旧链接'); + // 5分钟后再试一次 + setTimeout(() => { + if (hls && video && currentXiaoyaUrlRef.current) { + refreshXiaoyaUrl(hls, video, true); + } + }, 5 * 60 * 1000); + return false; + } + + // 错误触发的刷新失败,如果还有重试次数,延迟后再试 + if (retryCountRef.current < 3) { + console.log(`[链接刷新] 2秒后重试 (${retryCountRef.current}/3)`); + setTimeout(() => { + if (hls && video && currentXiaoyaUrlRef.current) { + refreshXiaoyaUrl(hls, video, false); + } + }, 2000); + } else { + setVideoError('链接刷新失败,请手动刷新页面'); + hls.destroy(); + } + + return false; + } + }; + + /** + * 启动14分钟定时刷新器 + */ + const startRefreshTimer = (hls: any, video: HTMLVideoElement) => { + // 清除旧定时器 + if (refreshTimerRef.current) { + clearTimeout(refreshTimerRef.current); + refreshTimerRef.current = null; + } + + // 只对xiaoya源启动定时器 + if (!currentXiaoyaUrlRef.current) { + return; + } + + console.log('[定时刷新] 启动14分钟定时器'); + + // 14分钟 = 840000毫秒 + refreshTimerRef.current = setTimeout(() => { + console.log('[定时刷新] 14分钟到期,开始刷新'); + if (hls && video && currentXiaoyaUrlRef.current) { + refreshXiaoyaUrl(hls, video, true); + } + }, 14 * 60 * 1000); + }; + + /** + * 清除刷新定时器 + */ + const clearRefreshTimer = () => { + if (refreshTimerRef.current) { + console.log('[定时刷新] 清除定时器'); + clearTimeout(refreshTimerRef.current); + refreshTimerRef.current = null; + } + }; + // 更新视频地址 const updateVideoUrl = async ( detailData: SearchResult | null, episodeIndex: number ) => { + // 重置刷新相关状态 + retryCountRef.current = 0; + lastRefreshTimeRef.current = 0; + currentXiaoyaUrlRef.current = ''; // 清空旧的原始URL + clearRefreshTimer(); // 清除旧的定时器 + // 动态设置 referrer policy:只在小雅源时不发送 Referer const existingMeta = document.querySelector('meta[name="referrer"]'); if (detailData?.source === 'xiaoya') { @@ -1507,6 +1745,9 @@ function PlayPageClient() { // 如果是小雅或 openlist 接口,先请求获取真实 URL if (newUrl.startsWith('/api/xiaoya/play') || newUrl.startsWith('/api/openlist/play')) { try { + // 保存原始URL(用于后续刷新) + currentXiaoyaUrlRef.current = newUrl; + // 添加 format=json 参数 const separator = newUrl.includes('?') ? '&' : '?'; const fetchUrl = `${newUrl}${separator}format=json`; @@ -1525,6 +1766,7 @@ function PlayPageClient() { } catch (error) { console.error('获取播放链接失败:', error); setVideoQualities([]); + currentXiaoyaUrlRef.current = ''; // 获取失败,清空 } } else { // 非小雅/openlist 源,清空清晰度列表 @@ -1719,6 +1961,9 @@ function PlayPageClient() { // 清理播放器资源的统一函数 const cleanupPlayer = async () => { + // 清除刷新定时器 + clearRefreshTimer(); + // 先清理Anime4K,避免GPU纹理错误 await cleanupAnime4K(); @@ -4445,6 +4690,16 @@ function PlayPageClient() { (video as any).playsInline = true; (video as any).webkitPlaysInline = true; + // 监听Manifest加载完成事件,启动xiaoya链接定时刷新 + hls.on(Hls.Events.MANIFEST_PARSED, () => { + console.log('[HLS] Manifest解析完成'); + + // 如果是xiaoya源的m3u8,启动14分钟定时刷新 + if (currentXiaoyaUrlRef.current && url.includes('.m3u8')) { + startRefreshTimer(hls, video); + } + }); + hls.on(Hls.Events.ERROR, function (event: any, data: any) { console.error('HLS Error:', event, data); if (data.fatal) { @@ -4453,9 +4708,21 @@ function PlayPageClient() { // 检查是否是 manifest 加载错误(通常是 403/404/CORS 错误) if (data.details === 'manifestLoadError') { console.log('Manifest 加载失败:可能是 403/404 或 CORS 错误'); - hls.destroy(); - // 检查是否有响应码 + const statusCode = data.response?.code || data.response?.status; + + // 如果是403且是xiaoya源的m3u8,尝试自动刷新 + if (statusCode === 403 && currentXiaoyaUrlRef.current) { + const isM3u8 = url.includes('.m3u8') || url.includes('m3u8'); + if (isM3u8) { + console.log('[HLS错误] 检测到403,尝试刷新链接'); + refreshXiaoyaUrl(hls, video, false); + return; // 不执行后续的错误处理 + } + } + + // 原有的错误处理逻辑 + hls.destroy(); if (statusCode === 403) { setVideoError('访问被拒绝 (403)'); } else if (statusCode === 404) { @@ -6712,6 +6979,19 @@ function PlayPageClient() { )} + {/* 链接刷新提示 */} + {isRefreshingUrl && ( +
+
+ + + + + 正在刷新链接... +
+
+ )} + {/* 弹幕加载蒙层 */} {danmakuLoading && (