修正detail
This commit is contained in:
78
src/app/api/openlist/play/route.ts
Normal file
78
src/app/api/openlist/play/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
213
src/app/api/source-detail/route.ts
Normal file
213
src/app/api/source-detail/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1945,11 +1945,12 @@ function PlayPageClient() {
|
||||
useEffect(() => {
|
||||
const fetchSourceDetail = async (
|
||||
source: string,
|
||||
id: string
|
||||
id: string,
|
||||
title: string
|
||||
): Promise<SearchResult[]> => {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user