diff --git a/src/app/api/openlist/play/route.ts b/src/app/api/openlist/play/route.ts index 371c373..a9ef8dd 100644 --- a/src/app/api/openlist/play/route.ts +++ b/src/app/api/openlist/play/route.ts @@ -9,9 +9,59 @@ import { OpenListClient } from '@/lib/openlist.client'; export const runtime = 'nodejs'; /** - * GET /api/openlist/play?folder=xxx&fileName=xxx - * 获取单个视频文件的播放链接(懒加载) - * 返回重定向到真实播放 URL + * 使用 HEAD 请求跟随重定向获取最终 URL(直连方法 - 降级使用) + */ +async function getFinalUrl(url: string, maxRedirects = 5): Promise { + let currentUrl = url; + let redirectCount = 0; + + while (redirectCount < maxRedirects) { + try { + const response = await fetch(currentUrl, { + method: 'HEAD', + redirect: 'manual', + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + }, + }); + + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location'); + if (!location) { + return currentUrl; + } + + if (location.startsWith('http://') || location.startsWith('https://')) { + currentUrl = location; + } else if (location.startsWith('/')) { + const urlObj = new URL(currentUrl); + currentUrl = `${urlObj.protocol}//${urlObj.host}${location}`; + } else { + const urlObj = new URL(currentUrl); + const pathParts = urlObj.pathname.split('/'); + pathParts.pop(); + pathParts.push(location); + currentUrl = `${urlObj.protocol}//${urlObj.host}${pathParts.join('/')}`; + } + + redirectCount++; + } else { + return currentUrl; + } + } catch (error) { + console.error('[openlist/play] 获取最终 URL 失败:', error); + return currentUrl; + } + } + + return currentUrl; +} + +/** + * GET /api/openlist/play?folder=xxx&fileName=xxx&format=json + * 获取单个视频文件的播放链接(优先使用视频预览流,失败时降级到直连) + * format=json: 返回 JSON 格式(用于 play 页面) + * 默认: 返回重定向(用于 tvbox 等) */ export async function GET(request: NextRequest) { try { @@ -23,6 +73,7 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const folderName = searchParams.get('folder'); const fileName = searchParams.get('fileName'); + const format = searchParams.get('format'); // 新增 format 参数 if (!folderName || !fileName) { return NextResponse.json({ error: '缺少参数' }, { status: 400 }); @@ -51,23 +102,71 @@ export async function GET(request: NextRequest) { openListConfig.Password ); - // 获取文件的播放链接 - const fileResponse = await client.getFile(filePath); + // 优先尝试视频预览流方法 + try { + const data = await client.getVideoPreview(filePath); - if (fileResponse.code !== 200 || !fileResponse.data.raw_url) { - console.error('[OpenList Play] 获取播放URL失败:', { - fileName, - code: fileResponse.code, - message: fileResponse.message, - }); - return NextResponse.json( - { error: '获取播放链接失败' }, - { status: 500 } - ); + const taskList = data.data?.video_preview_play_info?.live_transcoding_task_list; + if (!taskList || taskList.length === 0) { + throw new Error('未找到可用的播放链接'); + } + + const qualityOrder: Record = { + 'FHD': 1, + 'HD': 2, + 'LD': 3, + 'SD': 4, + }; + + const qualities = taskList + .filter((task: any) => task.status === 'finished') + .map((task: any) => ({ + name: task.template_id, + url: task.url, + })) + .sort((a: any, b: any) => (qualityOrder[a.name] || 999) - (qualityOrder[b.name] || 999)); + + if (qualities.length === 0) { + throw new Error('未找到已完成的播放链接'); + } + + // 如果指定了 format=json,返回 JSON 格式 + if (format === 'json') { + return NextResponse.json({ + url: qualities[0].url, + qualities + }); + } + + // 默认返回重定向(用于 tvbox) + return NextResponse.redirect(qualities[0].url); + } catch (error) { + // 视频预览流失败,降级到直连方法 + console.log('[openlist/play] 视频预览流失败,降级到直连方法:', (error as Error).message); + + const fileResponse = await client.getFile(filePath); + + if (fileResponse.code !== 200 || !fileResponse.data.raw_url) { + console.error('[OpenList Play] 获取播放URL失败:', { + fileName, + code: fileResponse.code, + message: fileResponse.message, + }); + return NextResponse.json( + { error: '获取播放链接失败' }, + { status: 500 } + ); + } + + // 如果指定了 format=json,使用 getFinalUrl 并返回 JSON + if (format === 'json') { + const finalUrl = await getFinalUrl(fileResponse.data.raw_url); + return NextResponse.json({ url: finalUrl }); + } + + // 默认返回重定向(用于 tvbox) + return NextResponse.redirect(fileResponse.data.raw_url); } - - // 返回重定向到真实播放 URL - return NextResponse.redirect(fileResponse.data.raw_url); } catch (error) { console.error('获取播放链接失败:', error); return NextResponse.json( diff --git a/src/app/api/xiaoya/play/route.ts b/src/app/api/xiaoya/play/route.ts index e564dd3..ee5cb02 100644 --- a/src/app/api/xiaoya/play/route.ts +++ b/src/app/api/xiaoya/play/route.ts @@ -58,9 +58,11 @@ async function getFinalUrl(url: string, maxRedirects = 5): Promise { } /** - * GET /api/xiaoya/play?path= + * GET /api/xiaoya/play?path=&format=json * 获取小雅视频的播放链接(优先使用视频预览流,失败时降级到直连) * path参数为base58编码的路径 + * format=json: 返回 JSON 格式(用于 play 页面) + * 默认: 返回重定向(用于 tvbox 等) */ export async function GET(request: NextRequest) { try { @@ -71,6 +73,7 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const encodedPath = searchParams.get('path'); + const format = searchParams.get('format'); // 新增 format 参数 if (!encodedPath) { return NextResponse.json({ error: '缺少参数' }, { status: 400 }); @@ -149,18 +152,30 @@ export async function GET(request: NextRequest) { throw new Error('未找到已完成的播放链接'); } - return NextResponse.json({ - url: qualities[0].url, - qualities - }); + // 如果指定了 format=json,返回 JSON 格式 + if (format === 'json') { + return NextResponse.json({ + url: qualities[0].url, + qualities + }); + } + + // 默认返回重定向(用于 tvbox) + return NextResponse.redirect(qualities[0].url); } catch (error) { // 视频预览流失败,降级到直连方法 console.log('[xiaoya/play] 视频预览流失败,降级到直连方法:', (error as Error).message); const playUrl = await client.getDownloadUrl(path); - const finalUrl = await getFinalUrl(playUrl); - return NextResponse.json({ url: finalUrl }); + // 如果指定了 format=json,使用 getFinalUrl 并返回 JSON + if (format === 'json') { + const finalUrl = await getFinalUrl(playUrl); + return NextResponse.json({ url: finalUrl }); + } + + // 默认返回重定向(用于 tvbox) + return NextResponse.redirect(playUrl); } } catch (error) { return NextResponse.json( diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 4f595d3..c2fb11a 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -1498,10 +1498,14 @@ function PlayPageClient() { let newUrl = detailData?.episodes[episodeIndex] || ''; - // 如果是小雅接口,先请求获取真实 URL - if (newUrl.startsWith('/api/xiaoya/play')) { + // 如果是小雅或 openlist 接口,先请求获取真实 URL + if (newUrl.startsWith('/api/xiaoya/play') || newUrl.startsWith('/api/openlist/play')) { try { - const response = await fetch(newUrl); + // 添加 format=json 参数 + const separator = newUrl.includes('?') ? '&' : '?'; + const fetchUrl = `${newUrl}${separator}format=json`; + + const response = await fetch(fetchUrl); const data = await response.json(); if (data.url) { newUrl = data.url; @@ -1513,11 +1517,11 @@ function PlayPageClient() { } } } catch (error) { - console.error('获取小雅播放链接失败:', error); + console.error('获取播放链接失败:', error); setVideoQualities([]); } } else { - // 非小雅源,清空清晰度列表 + // 非小雅/openlist 源,清空清晰度列表 setVideoQualities([]); } diff --git a/src/lib/openlist.client.ts b/src/lib/openlist.client.ts index 7d4bdcc..612c9f8 100644 --- a/src/lib/openlist.client.ts +++ b/src/lib/openlist.client.ts @@ -280,6 +280,31 @@ export class OpenListClient { } } + // 获取视频预览流 + async getVideoPreview(path: string): Promise { + const response = await this.fetchWithRetry(`${this.baseURL}/api/fs/other`, { + method: 'POST', + headers: await this.getHeaders(), + body: JSON.stringify({ + path: path, + method: 'video_preview', + password: '', + }), + }); + + if (!response.ok) { + throw new Error(`视频预览请求失败: ${response.status}`); + } + + const data = await response.json(); + + if (data.code !== 200) { + throw new Error(`视频预览失败: ${data.message}`); + } + + return data; + } + // 检查连通性 async checkConnectivity(): Promise<{ success: boolean; message: string }> { try {