From 4c7f7728633b871357c3d4b18c515a8fcf267d51 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 29 Dec 2025 22:58:41 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/openlist/play/route.ts | 78 +++++++++++ src/app/api/source-detail/route.ts | 213 +++++++++++++++++++++++++++++ src/app/play/page.tsx | 13 +- 3 files changed, 298 insertions(+), 6 deletions(-) create mode 100644 src/app/api/openlist/play/route.ts create mode 100644 src/app/api/source-detail/route.ts diff --git a/src/app/api/openlist/play/route.ts b/src/app/api/openlist/play/route.ts new file mode 100644 index 0000000..ea7c812 --- /dev/null +++ b/src/app/api/openlist/play/route.ts @@ -0,0 +1,78 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, no-console */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getConfig } from '@/lib/config'; +import { OpenListClient } from '@/lib/openlist.client'; + +export const runtime = 'nodejs'; + +/** + * GET /api/openlist/play?folder=xxx&fileName=xxx + * 获取单个视频文件的播放链接(懒加载) + * 返回重定向到真实播放 URL + */ +export async function GET(request: NextRequest) { + try { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: '未授权' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const folderName = searchParams.get('folder'); + const fileName = searchParams.get('fileName'); + + if (!folderName || !fileName) { + return NextResponse.json({ error: '缺少参数' }, { status: 400 }); + } + + const config = await getConfig(); + const openListConfig = config.OpenListConfig; + + if ( + !openListConfig || + !openListConfig.Enabled || + !openListConfig.URL || + !openListConfig.Username || + !openListConfig.Password + ) { + return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 }); + } + + const rootPath = openListConfig.RootPath || '/'; + const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folderName}`; + const filePath = `${folderPath}/${fileName}`; + + const client = new OpenListClient( + openListConfig.URL, + openListConfig.Username, + openListConfig.Password + ); + + // 获取文件的播放链接 + 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 } + ); + } + + // 返回重定向到真实播放 URL + return NextResponse.redirect(fileResponse.data.raw_url); + } catch (error) { + console.error('获取播放链接失败:', error); + return NextResponse.json( + { error: '获取失败', details: (error as Error).message }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/source-detail/route.ts b/src/app/api/source-detail/route.ts new file mode 100644 index 0000000..cd98192 --- /dev/null +++ b/src/app/api/source-detail/route.ts @@ -0,0 +1,213 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config'; +import { searchFromApi } from '@/lib/downstream'; + +export const runtime = 'nodejs'; + +/** + * 根据 source 和 id 从搜索结果中精确匹配获取视频详情 + * 这个API专门用于play页面快速获取当前源的详情 + */ +export async function GET(request: NextRequest) { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + const sourceCode = searchParams.get('source'); + const title = searchParams.get('title'); // 用于搜索的标题 + + if (!id || !sourceCode || !title) { + return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }); + } + + // 特殊处理 openlist 源 - 直接调用 /api/detail + if (sourceCode === 'openlist') { + try { + const config = await getConfig(); + const openListConfig = config.OpenListConfig; + + if ( + !openListConfig || + !openListConfig.Enabled || + !openListConfig.URL || + !openListConfig.Username || + !openListConfig.Password + ) { + throw new Error('OpenList 未配置或未启用'); + } + + const rootPath = openListConfig.RootPath || '/'; + + // 1. 读取 metainfo 获取元数据 + let metaInfo: any = null; + let folderMeta: any = null; + try { + const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache'); + const { db } = await import('@/lib/db'); + + metaInfo = getCachedMetaInfo(rootPath); + + if (!metaInfo) { + const metainfoJson = await db.getGlobalValue('video.metainfo'); + if (metainfoJson) { + metaInfo = JSON.parse(metainfoJson); + setCachedMetaInfo(rootPath, metaInfo); + } + } + + // 使用 key 查找文件夹信息 + folderMeta = metaInfo?.folders?.[id]; + if (!folderMeta) { + throw new Error('未找到该视频信息'); + } + } catch (error) { + throw new Error('读取视频信息失败: ' + (error as Error).message); + } + + // 使用 folderName 构建实际路径 + const folderName = folderMeta.folderName; + const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folderName}`; + + // 2. 直接调用 OpenList 客户端获取视频列表 + const { OpenListClient } = await import('@/lib/openlist.client'); + const { getCachedVideoInfo, setCachedVideoInfo } = await import('@/lib/openlist-cache'); + const { parseVideoFileName } = await import('@/lib/video-parser'); + + const client = new OpenListClient( + openListConfig.URL, + openListConfig.Username, + openListConfig.Password + ); + + let videoInfo = getCachedVideoInfo(folderPath); + + const listResponse = await client.listDirectory(folderPath); + + if (listResponse.code !== 200) { + throw new Error('OpenList 列表获取失败'); + } + + const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm', '.rmvb', '.rm', '.mpg', '.mpeg', '.3gp', '.f4v', '.m4v', '.vob']; + const videoFiles = listResponse.data.content.filter((item) => { + if (item.is_dir || item.name.startsWith('.') || item.name.endsWith('.json')) return false; + return videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext)); + }); + + if (!videoInfo) { + videoInfo = { episodes: {}, last_updated: Date.now() }; + videoFiles.sort((a, b) => a.name.localeCompare(b.name)); + for (let i = 0; i < videoFiles.length; i++) { + const file = videoFiles[i]; + const parsed = parseVideoFileName(file.name); + videoInfo.episodes[file.name] = { + episode: parsed.episode || (i + 1), + season: parsed.season, + title: parsed.title, + parsed_from: 'filename', + }; + } + setCachedVideoInfo(folderPath, videoInfo); + } + + const episodes = videoFiles + .map((file, index) => { + const parsed = parseVideoFileName(file.name); + let episodeInfo; + if (parsed.episode) { + episodeInfo = { episode: parsed.episode, season: parsed.season, title: parsed.title, parsed_from: 'filename' }; + } else { + episodeInfo = videoInfo!.episodes[file.name] || { episode: index + 1, season: undefined, title: undefined, parsed_from: 'filename' }; + } + let displayTitle = episodeInfo.title; + if (!displayTitle && episodeInfo.episode) { + displayTitle = `第${episodeInfo.episode}集`; + } + if (!displayTitle) { + displayTitle = file.name; + } + return { fileName: file.name, episode: episodeInfo.episode || 0, season: episodeInfo.season, title: displayTitle }; + }) + .sort((a, b) => a.episode !== b.episode ? a.episode - b.episode : a.fileName.localeCompare(b.fileName)); + + // 3. 从 metainfo 中获取元数据 + const { getTMDBImageUrl } = await import('@/lib/tmdb.search'); + + const result = { + source: 'openlist', + source_name: '私人影库', + id: id, + title: folderMeta?.title || folderName, + poster: folderMeta?.poster_path ? getTMDBImageUrl(folderMeta.poster_path) : '', + year: folderMeta?.release_date ? folderMeta.release_date.split('-')[0] : '', + douban_id: 0, + desc: folderMeta?.overview || '', + episodes: episodes.map((ep) => `/api/openlist/play?folder=${encodeURIComponent(folderName)}&fileName=${encodeURIComponent(ep.fileName)}`), + episodes_titles: episodes.map((ep) => ep.title), + proxyMode: false, // openlist 源不使用代理模式 + }; + + return NextResponse.json(result); + } catch (error) { + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ); + } + } + + // 对于其他源,通过搜索API获取,然后精确匹配 + try { + const apiSites = await getAvailableApiSites(authInfo.username); + const apiSite = apiSites.find((site) => site.key === sourceCode); + + if (!apiSite) { + return NextResponse.json({ error: '无效的API来源' }, { status: 400 }); + } + + // 调用搜索API + const searchResults = await searchFromApi(apiSite, title.trim()); + + // 从搜索结果中精确匹配 source 和 id + const exactMatch = searchResults.find( + (item: any) => + item.source?.toString() === sourceCode.toString() && + item.id?.toString() === id.toString() + ); + + if (!exactMatch) { + return NextResponse.json( + { error: '未找到匹配的视频源' }, + { status: 404 } + ); + } + + // 添加 proxyMode 到返回结果 + const resultWithProxy = { + ...exactMatch, + proxyMode: apiSite.proxyMode || false, + }; + + const cacheTime = await getCacheTime(); + + return NextResponse.json(resultWithProxy, { + headers: { + 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, + 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`, + 'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`, + 'Netlify-Vary': 'query', + }, + }); + } catch (error) { + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ); + } +} diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 66c0396..32a6978 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -1945,11 +1945,12 @@ function PlayPageClient() { useEffect(() => { const fetchSourceDetail = async ( source: string, - id: string + id: string, + title: string ): Promise => { try { const detailResponse = await fetch( - `/api/detail?source=${source}&id=${id}` + `/api/source-detail?source=${source}&id=${id}&title=${encodeURIComponent(title)}` ); if (!detailResponse.ok) { throw new Error('获取视频详情失败'); @@ -2059,7 +2060,7 @@ function PlayPageClient() { if (currentSource && currentId) { // 先快速获取当前源的详情 try { - const currentSourceDetail = await fetchSourceDetail(currentSource, currentId); + const currentSourceDetail = await fetchSourceDetail(currentSource, currentId, searchTitle || videoTitle); if (currentSourceDetail.length > 0) { detailData = currentSourceDetail[0]; sourcesInfo = currentSourceDetail; @@ -2110,7 +2111,7 @@ function PlayPageClient() { // 如果是 openlist 源且 episodes 为空,需要调用 detail 接口获取完整信息 if (detailData.source === 'openlist' && (!detailData.episodes || detailData.episodes.length === 0)) { console.log('[Play] OpenList source has no episodes, fetching detail...'); - const detailSources = await fetchSourceDetail(currentSource, currentId); + const detailSources = await fetchSourceDetail(currentSource, currentId, searchTitle || videoTitle); if (detailSources.length > 0) { detailData = detailSources[0]; } @@ -2138,7 +2139,7 @@ function PlayPageClient() { // 如果是 openlist 源且 episodes 为空,需要调用 detail 接口获取完整信息 if (detailData.source === 'openlist' && (!detailData.episodes || detailData.episodes.length === 0)) { console.log('[Play] OpenList source has no episodes after selection, fetching detail...'); - const detailSources = await fetchSourceDetail(detailData.source, detailData.id); + const detailSources = await fetchSourceDetail(detailData.source, detailData.id, detailData.title || videoTitleRef.current); if (detailSources.length > 0) { detailData = detailSources[0]; } @@ -2331,7 +2332,7 @@ function PlayPageClient() { // 如果是 openlist 源且 episodes 为空,需要调用 detail 接口获取完整信息 if (newDetail.source === 'openlist' && (!newDetail.episodes || newDetail.episodes.length === 0)) { try { - const detailResponse = await fetch(`/api/detail?source=${newSource}&id=${newId}`); + const detailResponse = await fetch(`/api/source-detail?source=${newSource}&id=${newId}&title=${encodeURIComponent(newTitle)}`); if (detailResponse.ok) { const detailData = await detailResponse.json(); if (!detailData) {