diff --git a/package.json b/package.json index 7040508..c9a3f25 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "next": "^14.2.33", "next-pwa": "^5.6.0", "next-themes": "^0.4.6", + "parse-torrent-name": "^0.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14e4b6f..2855873 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + parse-torrent-name: + specifier: ^0.5.4 + version: 0.5.4 react: specifier: ^18.3.1 version: 18.3.1 @@ -4218,6 +4221,9 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-torrent-name@0.5.4: + resolution: {integrity: sha512-digWcT7Zp/oZX8I7iTQSfWd3z3C/0zszo/xYQsmogO2a6XDU0sTlQXYffHRhuwXNivBvMB8mS+EAwciyyVBlGQ==} + parse5-htmlparser2-tree-adapter@7.1.0: resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} @@ -10401,6 +10407,8 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-torrent-name@0.5.4: {} + parse5-htmlparser2-tree-adapter@7.1.0: dependencies: domhandler: 5.0.3 diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index e415096..58e0674 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -2529,6 +2529,286 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { ); }; +// 私人影库配置组件 +const OpenListConfigComponent = ({ + config, + refreshConfig, +}: { + config: AdminConfig | null; + refreshConfig: () => Promise; +}) => { + const { alertModal, showAlert, hideAlert } = useAlertModal(); + const { isLoading, withLoading } = useLoadingState(); + const [url, setUrl] = useState(''); + const [token, setToken] = useState(''); + const [rootPath, setRootPath] = useState('/'); + const [videos, setVideos] = useState([]); + const [refreshing, setRefreshing] = useState(false); + + useEffect(() => { + if (config?.OpenListConfig) { + setUrl(config.OpenListConfig.URL || ''); + setToken(config.OpenListConfig.Token || ''); + setRootPath(config.OpenListConfig.RootPath || '/'); + } + }, [config]); + + useEffect(() => { + if (config?.OpenListConfig?.URL && config?.OpenListConfig?.Token) { + fetchVideos(); + } + }, [config]); + + const fetchVideos = async () => { + try { + setRefreshing(true); + const response = await fetch('/api/openlist/list?page=1&pageSize=100'); + if (response.ok) { + const data = await response.json(); + setVideos(data.list || []); + } + } catch (error) { + console.error('获取视频列表失败:', error); + } finally { + setRefreshing(false); + } + }; + + const handleSave = async () => { + await withLoading('saveOpenList', async () => { + try { + const response = await fetch('/api/admin/openlist', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'save', + URL: url, + Token: token, + RootPath: rootPath, + }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || '保存失败'); + } + + showSuccess('保存成功', showAlert); + await refreshConfig(); + } catch (error) { + showError(error instanceof Error ? error.message : '保存失败', showAlert); + throw error; + } + }); + }; + + const handleRefresh = async () => { + setRefreshing(true); + try { + const response = await fetch('/api/openlist/refresh', { + method: 'POST', + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || '刷新失败'); + } + + const result = await response.json(); + showSuccess( + `刷新成功!新增 ${result.new} 个,已存在 ${result.existing} 个,失败 ${result.errors} 个`, + showAlert + ); + await refreshConfig(); + await fetchVideos(); + } catch (error) { + showError(error instanceof Error ? error.message : '刷新失败', showAlert); + } finally { + setRefreshing(false); + } + }; + + const handleRefreshVideo = async (folder: string) => { + try { + const response = await fetch('/api/openlist/refresh-video', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ folder }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || '刷新失败'); + } + + showSuccess('刷新成功', showAlert); + } catch (error) { + showError(error instanceof Error ? error.message : '刷新失败', showAlert); + } + }; + + const formatDate = (timestamp?: number) => { + if (!timestamp) return '未刷新'; + return new Date(timestamp).toLocaleString('zh-CN'); + }; + + return ( +
+ {/* 配置表单 */} +
+
+ + setUrl(e.target.value)} + placeholder='https://your-openlist-server.com' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent' + /> +
+ +
+ + setToken(e.target.value)} + placeholder='your-token' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent' + /> +
+ +
+ + setRootPath(e.target.value)} + placeholder='/' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent' + /> +

+ OpenList 中的视频文件夹路径,默认为根目录 / +

+
+ +
+ +
+
+ + {/* 视频列表区域 */} + {config?.OpenListConfig?.URL && config?.OpenListConfig?.Token && ( +
+
+
+

+ 视频列表 +

+
+ 资源数: {config.OpenListConfig.ResourceCount || 0} + | + + 上次更新: {formatDate(config.OpenListConfig.LastRefreshTime)} + +
+
+ +
+ + {refreshing ? ( +
+ 加载中... +
+ ) : videos.length > 0 ? ( +
+ + + + + + + + + + + + {videos.map((video) => ( + + + + + + + + ))} + +
+ 标题 + + 类型 + + 年份 + + 评分 + + 操作 +
+ {video.title} + + {video.mediaType === 'movie' ? '电影' : '剧集'} + + {video.releaseDate.split('-')[0]} + + {video.voteAverage.toFixed(1)} + + +
+
+ ) : ( +
+ 暂无视频,请点击"立即扫描"扫描视频库 +
+ )} +
+ )} + + +
+ ); +}; + // 视频源配置组件 const VideoSourceConfig = ({ config, @@ -7017,6 +7297,7 @@ function AdminPageClient() { const [expandedTabs, setExpandedTabs] = useState<{ [key: string]: boolean }>({ userConfig: false, videoSource: false, + openListConfig: false, liveSource: false, siteConfig: false, registrationConfig: false, @@ -7231,6 +7512,18 @@ function AdminPageClient() { + {/* 私人影库配置标签 */} + + } + isExpanded={expandedTabs.openListConfig} + onToggle={() => toggleTab('openListConfig')} + > + + + {/* 直播源配置标签 */} u.username === username + ); + if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) { + return NextResponse.json({ error: '权限不足' }, { status: 401 }); + } + } + + if (action === 'save') { + // 保存配置 + if (!URL || !Token) { + return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }); + } + + adminConfig.OpenListConfig = { + URL, + Token, + RootPath: RootPath || '/', + LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime, + ResourceCount: adminConfig.OpenListConfig?.ResourceCount, + }; + + await db.saveAdminConfig(adminConfig); + + return NextResponse.json({ + success: true, + message: '保存成功', + }); + } + + return NextResponse.json({ error: '未知操作' }, { status: 400 }); + } catch (error) { + console.error('OpenList 配置操作失败:', error); + return NextResponse.json( + { error: '操作失败', details: (error as Error).message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/admin/source/route.ts b/src/app/api/admin/source/route.ts index 2efc3eb..f5c9ca4 100644 --- a/src/app/api/admin/source/route.ts +++ b/src/app/api/admin/source/route.ts @@ -66,6 +66,13 @@ export async function POST(request: NextRequest) { if (!key || !name || !api) { return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }); } + // 禁止添加 openlist 保留关键字 + if (key === 'openlist') { + return NextResponse.json( + { error: 'openlist 是保留关键字,不能作为视频源 key' }, + { status: 400 } + ); + } if (adminConfig.SourceConfig.some((s) => s.key === key)) { return NextResponse.json({ error: '该源已存在' }, { status: 400 }); } diff --git a/src/app/api/cms-proxy/route.ts b/src/app/api/cms-proxy/route.ts index 2b29ef7..15f634d 100644 --- a/src/app/api/cms-proxy/route.ts +++ b/src/app/api/cms-proxy/route.ts @@ -1,7 +1,16 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-explicit-any, no-console */ import { NextRequest, NextResponse } from 'next/server'; +import { getConfig } from '@/lib/config'; +import { OpenListClient } from '@/lib/openlist.client'; +import { + getCachedMetaInfo, + MetaInfo, + setCachedMetaInfo, +} from '@/lib/openlist-cache'; +import { getTMDBImageUrl } from '@/lib/tmdb.search'; + export const runtime = 'nodejs'; /** @@ -21,6 +30,11 @@ export async function GET(request: NextRequest) { ); } + // 特殊处理 openlist + if (apiUrl === 'openlist') { + return handleOpenListProxy(request); + } + // 构建完整的 API 请求 URL,包含所有查询参数 const targetUrl = new URL(apiUrl); @@ -237,3 +251,177 @@ function processUrl(url: string, playFrom: string, proxyOrigin: string, tokenPar // 非 m3u8 链接不处理 return url; } + +/** + * 处理 OpenList 代理请求 + */ +async function handleOpenListProxy(request: NextRequest) { + const { searchParams } = new URL(request.url); + const wd = searchParams.get('wd'); // 搜索关键词 + const ids = searchParams.get('ids'); // 详情ID + + const config = await getConfig(); + const openListConfig = config.OpenListConfig; + + if (!openListConfig || !openListConfig.URL || !openListConfig.Token) { + return NextResponse.json( + { code: 0, msg: 'OpenList 未配置', list: [] }, + { status: 200 } + ); + } + + const rootPath = openListConfig.RootPath || '/'; + const client = new OpenListClient(openListConfig.URL, openListConfig.Token); + + // 读取 metainfo.json + let metaInfo: MetaInfo | null = getCachedMetaInfo(rootPath); + + if (!metaInfo) { + try { + const metainfoPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}metainfo.json`; + const fileResponse = await client.getFile(metainfoPath); + + if (fileResponse.code === 200 && fileResponse.data.raw_url) { + const downloadUrl = fileResponse.data.raw_url; + const contentResponse = await fetch(downloadUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': '*/*', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + }, + }); + const content = await contentResponse.text(); + metaInfo = JSON.parse(content); + setCachedMetaInfo(rootPath, metaInfo); + } + } catch (error) { + return NextResponse.json( + { code: 0, msg: 'metainfo.json 不存在', list: [] }, + { status: 200 } + ); + } + } + + if (!metaInfo) { + return NextResponse.json( + { code: 0, msg: '无数据', list: [] }, + { status: 200 } + ); + } + + // 搜索模式 + if (wd) { + const results = Object.entries(metaInfo.folders) + .filter( + ([folderName, info]) => + folderName.toLowerCase().includes(wd.toLowerCase()) || + info.title.toLowerCase().includes(wd.toLowerCase()) + ) + .map(([folderName, info]) => ({ + vod_id: folderName, + vod_name: info.title, + vod_pic: getTMDBImageUrl(info.poster_path), + vod_remarks: info.media_type === 'movie' ? '电影' : '剧集', + vod_year: info.release_date.split('-')[0] || '', + type_name: info.media_type === 'movie' ? '电影' : '电视剧', + })); + + return NextResponse.json({ + code: 1, + msg: '数据列表', + page: 1, + pagecount: 1, + limit: results.length, + total: results.length, + list: results, + }); + } + + // 详情模式 + if (ids) { + const folderName = ids; + const info = metaInfo.folders[folderName]; + + if (!info) { + return NextResponse.json( + { code: 0, msg: '视频不存在', list: [] }, + { status: 200 } + ); + } + + // 获取视频详情 + try { + const detailResponse = await fetch( + `${request.headers.get('x-forwarded-proto') || 'http'}://${request.headers.get('host')}/api/openlist/detail?folder=${encodeURIComponent(folderName)}` + ); + + if (!detailResponse.ok) { + throw new Error('获取视频详情失败'); + } + + const detailData = await detailResponse.json(); + + if (!detailData.success) { + throw new Error('获取视频详情失败'); + } + + // 构建播放列表 + const playUrls = detailData.episodes + .map((ep: any) => { + const title = ep.title || `第${ep.episode}集`; + return `${title}$${ep.playUrl}`; + }) + .join('#'); + + return NextResponse.json({ + code: 1, + msg: '数据列表', + page: 1, + pagecount: 1, + limit: 1, + total: 1, + list: [ + { + vod_id: folderName, + vod_name: info.title, + vod_pic: getTMDBImageUrl(info.poster_path), + vod_remarks: info.media_type === 'movie' ? '电影' : '剧集', + vod_year: info.release_date.split('-')[0] || '', + vod_content: info.overview, + vod_play_from: 'OpenList', + vod_play_url: playUrls, + type_name: info.media_type === 'movie' ? '电影' : '电视剧', + }, + ], + }); + } catch (error) { + console.error('获取 OpenList 视频详情失败:', error); + return NextResponse.json( + { code: 0, msg: '获取详情失败', list: [] }, + { status: 200 } + ); + } + } + + // 默认返回所有视频 + const results = Object.entries(metaInfo.folders).map( + ([folderName, info]) => ({ + vod_id: folderName, + vod_name: info.title, + vod_pic: getTMDBImageUrl(info.poster_path), + vod_remarks: info.media_type === 'movie' ? '电影' : '剧集', + vod_year: info.release_date.split('-')[0] || '', + type_name: info.media_type === 'movie' ? '电影' : '电视剧', + }) + ); + + return NextResponse.json({ + code: 1, + msg: '数据列表', + page: 1, + pagecount: 1, + limit: results.length, + total: results.length, + list: results, + }); +} diff --git a/src/app/api/detail/route.ts b/src/app/api/detail/route.ts index 59dba3b..21e3a41 100644 --- a/src/app/api/detail/route.ts +++ b/src/app/api/detail/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; -import { getAvailableApiSites, getCacheTime } from '@/lib/config'; +import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config'; import { getDetailFromApi } from '@/lib/downstream'; export const runtime = 'nodejs'; @@ -20,6 +20,100 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }); } + // 特殊处理 openlist 源 + if (sourceCode === 'openlist') { + try { + const config = await getConfig(); + const openListConfig = config.OpenListConfig; + + if (!openListConfig || !openListConfig.URL || !openListConfig.Token) { + throw new Error('OpenList 未配置'); + } + + const rootPath = openListConfig.RootPath || '/'; + + // 1. 读取 metainfo.json 获取元数据 + let metaInfo: any = null; + try { + const { OpenListClient } = await import('@/lib/openlist.client'); + const { getCachedMetaInfo } = await import('@/lib/openlist-cache'); + const { getTMDBImageUrl } = await import('@/lib/tmdb.search'); + + const client = new OpenListClient(openListConfig.URL, openListConfig.Token); + metaInfo = getCachedMetaInfo(rootPath); + + if (!metaInfo) { + const metainfoPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}metainfo.json`; + const fileResponse = await client.getFile(metainfoPath); + + if (fileResponse.code === 200 && fileResponse.data.raw_url) { + const downloadUrl = fileResponse.data.raw_url; + const contentResponse = await fetch(downloadUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': '*/*', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + }, + }); + const content = await contentResponse.text(); + metaInfo = JSON.parse(content); + } + } + } catch (error) { + console.error('[Detail] 读取 metainfo.json 失败:', error); + } + + // 2. 调用 openlist detail API + const openlistResponse = await fetch( + `${request.headers.get('x-forwarded-proto') || 'http'}://${request.headers.get('host')}/api/openlist/detail?folder=${encodeURIComponent(id)}`, + { + headers: { + Cookie: request.headers.get('cookie') || '', + }, + } + ); + + if (!openlistResponse.ok) { + throw new Error('获取 OpenList 视频详情失败'); + } + + const openlistData = await openlistResponse.json(); + + if (!openlistData.success) { + throw new Error(openlistData.error || '获取视频详情失败'); + } + + // 3. 从 metainfo 中获取元数据 + const folderMeta = metaInfo?.folders?.[id]; + const { getTMDBImageUrl } = await import('@/lib/tmdb.search'); + + // 转换为标准格式(使用懒加载 URL) + const result = { + source: 'openlist', + source_name: '私人影库', + id: openlistData.folder, + title: folderMeta?.title || openlistData.folder, + poster: folderMeta?.poster_path ? getTMDBImageUrl(folderMeta.poster_path) : '', + year: folderMeta?.release_date ? folderMeta.release_date.split('-')[0] : '', + douban_id: 0, + desc: folderMeta?.overview || '', + episodes: openlistData.episodes.map((ep: any) => + `/api/openlist/play?folder=${encodeURIComponent(openlistData.folder)}&fileName=${encodeURIComponent(ep.fileName)}` + ), + episodes_titles: openlistData.episodes.map((ep: any) => ep.title || `第${ep.episode}集`), + }; + + console.log('[Detail] result.episodes_titles:', result.episodes_titles); + + return NextResponse.json(result); + } catch (error) { + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ); + } + } + if (!/^[\w-]+$/.test(id)) { return NextResponse.json({ error: '无效的视频ID格式' }, { status: 400 }); } diff --git a/src/app/api/openlist/detail/route.ts b/src/app/api/openlist/detail/route.ts new file mode 100644 index 0000000..d4b3648 --- /dev/null +++ b/src/app/api/openlist/detail/route.ts @@ -0,0 +1,228 @@ +/* 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'; +import { + getCachedVideoInfo, + setCachedVideoInfo, + VideoInfo, +} from '@/lib/openlist-cache'; +import { parseVideoFileName } from '@/lib/video-parser'; + +export const runtime = 'nodejs'; + +/** + * GET /api/openlist/detail?folder=xxx + * 获取视频文件夹的详细信息 + */ +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'); + + if (!folderName) { + return NextResponse.json({ error: '缺少参数' }, { status: 400 }); + } + + const config = await getConfig(); + const openListConfig = config.OpenListConfig; + + if (!openListConfig || !openListConfig.URL || !openListConfig.Token) { + return NextResponse.json({ error: 'OpenList 未配置' }, { status: 400 }); + } + + const rootPath = openListConfig.RootPath || '/'; + const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folderName}`; + const client = new OpenListClient(openListConfig.URL, openListConfig.Token); + + // 1. 尝试读取缓存的 videoinfo.json + let videoInfo: VideoInfo | null = getCachedVideoInfo(folderPath); + + if (!videoInfo) { + // 2. 尝试从 OpenList 读取 videoinfo.json + try { + const videoinfoPath = `${folderPath}/videoinfo.json`; + const fileResponse = await client.getFile(videoinfoPath); + + if (fileResponse.code === 200 && fileResponse.data.raw_url) { + const downloadUrl = fileResponse.data.raw_url; + const contentResponse = await fetch(downloadUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': '*/*', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + }, + }); + const content = await contentResponse.text(); + videoInfo = JSON.parse(content); + + // 缓存 + if (videoInfo) { + setCachedVideoInfo(folderPath, videoInfo); + } + } + } catch (error) { + console.log('videoinfo.json 不存在,将解析文件名'); + } + } + + // 3. 如果没有 videoinfo.json,列出文件夹并解析 + if (!videoInfo) { + const listResponse = await client.listDirectory(folderPath); + + if (listResponse.code !== 200) { + return NextResponse.json( + { error: 'OpenList 列表获取失败' }, + { status: 500 } + ); + } + + // 过滤视频文件 + const videoFiles = listResponse.data.content.filter( + (item) => + !item.is_dir && + !item.name.endsWith('.json') && // 排除 JSON 文件 + !item.name.startsWith('.') && // 排除隐藏文件 + (item.name.endsWith('.mp4') || + item.name.endsWith('.mkv') || + item.name.endsWith('.avi') || + item.name.endsWith('.m3u8') || + item.name.endsWith('.flv') || + item.name.endsWith('.ts')) + ); + + 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), // 如果解析失败,使用索引+1作为集数 + season: parsed.season, + title: parsed.title, + parsed_from: 'filename', + }; + } + + // 保存 videoinfo.json + const videoinfoPath = `${folderPath}/videoinfo.json`; + await client.uploadFile( + videoinfoPath, + JSON.stringify(videoInfo, null, 2) + ); + + // 缓存 + setCachedVideoInfo(folderPath, videoInfo); + } + + // 4. 获取视频文件列表(不获取播放链接,使用懒加载) + const listResponse = await client.listDirectory(folderPath); + + // 定义视频文件扩展名(不区分大小写) + 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) return false; + + // 排除隐藏文件 + if (item.name.startsWith('.')) return false; + + // 排除 JSON 文件 + if (item.name.endsWith('.json')) return false; + + // 检查是否是视频文件(不区分大小写) + const lowerName = item.name.toLowerCase(); + return videoExtensions.some(ext => lowerName.endsWith(ext)); + }); + + // 5. 构建集数信息(不包含播放链接) + // 确保所有视频文件都被显示,即使 videoInfo 中没有记录 + const episodes = videoFiles + .map((file, index) => { + // 总是重新解析文件名,确保使用最新的解析逻辑 + const parsed = parseVideoFileName(file.name); + + // 如果解析成功,使用解析结果;否则使用 videoInfo 中的记录或索引 + let episodeInfo; + if (parsed.episode) { + episodeInfo = { + episode: parsed.episode, + season: parsed.season, + title: parsed.title, + parsed_from: 'filename', + }; + } else { + // 如果解析失败,尝试从 videoInfo 获取 + episodeInfo = videoInfo!.episodes[file.name]; + if (!episodeInfo) { + // 如果 videoInfo 中也没有,使用索引 + episodeInfo = { + episode: index + 1, + season: undefined, + title: undefined, + parsed_from: 'filename', + }; + } + } + + // 优先使用解析出的标题,其次是"第X集"格式,最后才是文件名 + 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, + size: file.size, + }; + }) + .sort((a, b) => { + // 确保排序稳定,即使 episode 相同也按文件名排序 + if (a.episode !== b.episode) { + return a.episode - b.episode; + } + return a.fileName.localeCompare(b.fileName); + }); + + return NextResponse.json({ + success: true, + folder: folderName, + episodes, + videoInfo, + }); + } catch (error) { + console.error('获取视频详情失败:', error); + return NextResponse.json( + { error: '获取失败', details: (error as Error).message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/openlist/list/route.ts b/src/app/api/openlist/list/route.ts new file mode 100644 index 0000000..ec2fd49 --- /dev/null +++ b/src/app/api/openlist/list/route.ts @@ -0,0 +1,193 @@ +/* eslint-disable no-console */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getConfig } from '@/lib/config'; +import { OpenListClient } from '@/lib/openlist.client'; +import { + getCachedMetaInfo, + MetaInfo, + setCachedMetaInfo, +} from '@/lib/openlist-cache'; +import { getTMDBImageUrl } from '@/lib/tmdb.search'; + +export const runtime = 'nodejs'; + +/** + * GET /api/openlist/list?page=1&pageSize=20 + * 获取私人影库视频列表 + */ +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 page = parseInt(searchParams.get('page') || '1'); + const pageSize = parseInt(searchParams.get('pageSize') || '20'); + + const config = await getConfig(); + const openListConfig = config.OpenListConfig; + + if (!openListConfig || !openListConfig.URL || !openListConfig.Token) { + return NextResponse.json( + { error: 'OpenList 未配置', list: [], total: 0 }, + { status: 200 } + ); + } + + const rootPath = openListConfig.RootPath || '/'; + const client = new OpenListClient(openListConfig.URL, openListConfig.Token); + + // 读取 metainfo.json + let metaInfo: MetaInfo | null = getCachedMetaInfo(rootPath); + + console.log('[OpenList List] 缓存检查:', { + rootPath, + hasCachedMetaInfo: !!metaInfo, + }); + + if (!metaInfo) { + try { + const metainfoPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}metainfo.json`; + console.log('[OpenList List] 尝试读取 metainfo.json:', metainfoPath); + + const fileResponse = await client.getFile(metainfoPath); + console.log('[OpenList List] getFile 完整响应:', JSON.stringify(fileResponse, null, 2)); + + if (fileResponse.code === 200 && fileResponse.data.raw_url) { + console.log('[OpenList List] 使用 raw_url 获取文件内容'); + + const downloadUrl = fileResponse.data.raw_url; + console.log('[OpenList List] 下载 URL:', downloadUrl); + + const contentResponse = await fetch(downloadUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': '*/*', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + }, + }); + console.log('[OpenList List] fetch 响应:', { + status: contentResponse.status, + ok: contentResponse.ok, + }); + + if (!contentResponse.ok) { + throw new Error(`获取文件内容失败: ${contentResponse.status}`); + } + + const content = await contentResponse.text(); + console.log('[OpenList List] 文件内容长度:', content.length); + console.log('[OpenList List] 文件内容预览:', content.substring(0, 200)); + + try { + metaInfo = JSON.parse(content); + console.log('[OpenList List] JSON 解析成功'); + console.log('[OpenList List] metaInfo 结构:', { + hasfolders: !!metaInfo?.folders, + foldersType: typeof metaInfo?.folders, + keys: metaInfo?.folders ? Object.keys(metaInfo.folders) : [], + }); + + // 验证数据结构 + if (!metaInfo || typeof metaInfo !== 'object') { + throw new Error('metaInfo 不是有效对象'); + } + if (!metaInfo.folders || typeof metaInfo.folders !== 'object') { + throw new Error('metaInfo.folders 不存在或不是对象'); + } + + console.log('[OpenList List] 解析成功,视频数量:', Object.keys(metaInfo.folders).length); + setCachedMetaInfo(rootPath, metaInfo); + } catch (parseError) { + console.error('[OpenList List] JSON 解析或验证失败:', parseError); + throw new Error(`JSON 解析失败: ${(parseError as Error).message}`); + } + } else { + console.error('[OpenList List] getFile 失败或无 sign:', { + code: fileResponse.code, + message: fileResponse.message, + data: fileResponse.data, + }); + throw new Error(`getFile 返回错误: code=${fileResponse.code}, message=${fileResponse.message}`); + } + } catch (error) { + console.error('[OpenList List] 读取 metainfo.json 失败:', error); + return NextResponse.json( + { + error: 'metainfo.json 读取失败', + details: (error as Error).message, + list: [], + total: 0, + }, + { status: 200 } + ); + } + } + + if (!metaInfo) { + console.error('[OpenList List] metaInfo 为 null'); + return NextResponse.json( + { error: '无数据', list: [], total: 0 }, + { status: 200 } + ); + } + + // 验证 metaInfo 结构 + if (!metaInfo.folders || typeof metaInfo.folders !== 'object') { + console.error('[OpenList List] metaInfo.folders 无效:', { + hasfolders: !!metaInfo.folders, + foldersType: typeof metaInfo.folders, + metaInfoKeys: Object.keys(metaInfo), + }); + return NextResponse.json( + { error: 'metainfo.json 结构无效', list: [], total: 0 }, + { status: 200 } + ); + } + + console.log('[OpenList List] 开始转换视频列表,视频数:', Object.keys(metaInfo.folders).length); + + // 转换为数组并分页 + const allVideos = Object.entries(metaInfo.folders).map( + ([folderName, info]) => ({ + id: folderName, + folder: folderName, + title: info.title, + poster: getTMDBImageUrl(info.poster_path), + releaseDate: info.release_date, + overview: info.overview, + voteAverage: info.vote_average, + mediaType: info.media_type, + lastUpdated: info.last_updated, + }) + ); + + // 按更新时间倒序排序 + allVideos.sort((a, b) => b.lastUpdated - a.lastUpdated); + + const total = allVideos.length; + const start = (page - 1) * pageSize; + const end = start + pageSize; + const list = allVideos.slice(start, end); + + return NextResponse.json({ + success: true, + list, + total, + page, + pageSize, + totalPages: Math.ceil(total / pageSize), + }); + } catch (error) { + console.error('获取视频列表失败:', error); + return NextResponse.json( + { error: '获取失败', details: (error as Error).message, list: [], total: 0 }, + { status: 500 } + ); + } +} diff --git a/src/app/api/openlist/play/route.ts b/src/app/api/openlist/play/route.ts new file mode 100644 index 0000000..86cfdd0 --- /dev/null +++ b/src/app/api/openlist/play/route.ts @@ -0,0 +1,68 @@ +/* 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.URL || !openListConfig.Token) { + 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.Token); + + // 获取文件的播放链接 + 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 } + ); + } +} diff --git a/src/app/api/openlist/refresh-video/route.ts b/src/app/api/openlist/refresh-video/route.ts new file mode 100644 index 0000000..8e1228d --- /dev/null +++ b/src/app/api/openlist/refresh-video/route.ts @@ -0,0 +1,64 @@ +/* eslint-disable no-console */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getConfig } from '@/lib/config'; +import { OpenListClient } from '@/lib/openlist.client'; +import { invalidateVideoInfoCache } from '@/lib/openlist-cache'; + +export const runtime = 'nodejs'; + +/** + * POST /api/openlist/refresh-video + * 刷新单个视频的 videoinfo.json + */ +export async function POST(request: NextRequest) { + try { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: '未授权' }, { status: 401 }); + } + + const body = await request.json(); + const { folder } = body; + + if (!folder) { + return NextResponse.json({ error: '缺少参数' }, { status: 400 }); + } + + const config = await getConfig(); + const openListConfig = config.OpenListConfig; + + if (!openListConfig || !openListConfig.URL || !openListConfig.Token) { + return NextResponse.json({ error: 'OpenList 未配置' }, { status: 400 }); + } + + const rootPath = openListConfig.RootPath || '/'; + const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folder}`; + const client = new OpenListClient(openListConfig.URL, openListConfig.Token); + + // 删除 videoinfo.json + const videoinfoPath = `${folderPath}/videoinfo.json`; + + try { + await client.deleteFile(videoinfoPath); + } catch (error) { + console.log('videoinfo.json 不存在或删除失败'); + } + + // 清除缓存 + invalidateVideoInfoCache(folderPath); + + return NextResponse.json({ + success: true, + message: '刷新成功', + }); + } catch (error) { + console.error('刷新视频失败:', error); + return NextResponse.json( + { error: '刷新失败', details: (error as Error).message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/openlist/refresh/route.ts b/src/app/api/openlist/refresh/route.ts new file mode 100644 index 0000000..73e95ce --- /dev/null +++ b/src/app/api/openlist/refresh/route.ts @@ -0,0 +1,280 @@ +/* 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 { db } from '@/lib/db'; +import { OpenListClient } from '@/lib/openlist.client'; +import { + getCachedMetaInfo, + invalidateMetaInfoCache, + MetaInfo, + setCachedMetaInfo, +} from '@/lib/openlist-cache'; +import { searchTMDB } from '@/lib/tmdb.search'; + +export const runtime = 'nodejs'; + +/** + * POST /api/openlist/refresh + * 刷新私人影库元数据 + */ +export async function POST(request: NextRequest) { + try { + // 权限检查 + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: '未授权' }, { status: 401 }); + } + + // 获取配置 + const config = await getConfig(); + const openListConfig = config.OpenListConfig; + + if (!openListConfig || !openListConfig.URL || !openListConfig.Token) { + return NextResponse.json( + { error: 'OpenList 未配置' }, + { status: 400 } + ); + } + + const tmdbApiKey = config.SiteConfig.TMDBApiKey; + const tmdbProxy = config.SiteConfig.TMDBProxy; + + if (!tmdbApiKey) { + return NextResponse.json( + { error: 'TMDB API Key 未配置' }, + { status: 400 } + ); + } + + const rootPath = openListConfig.RootPath || '/'; + const client = new OpenListClient(openListConfig.URL, openListConfig.Token); + + console.log('[OpenList Refresh] 开始刷新:', { + rootPath, + url: openListConfig.URL, + hasToken: !!openListConfig.Token, + }); + + // 1. 读取现有 metainfo.json (如果存在) + let existingMetaInfo: MetaInfo | null = getCachedMetaInfo(rootPath); + + if (!existingMetaInfo) { + try { + const metainfoPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}metainfo.json`; + console.log('[OpenList Refresh] 尝试读取现有 metainfo.json:', metainfoPath); + + const fileResponse = await client.getFile(metainfoPath); + console.log('[OpenList Refresh] getFile 完整响应:', JSON.stringify(fileResponse, null, 2)); + + if (fileResponse.code === 200 && fileResponse.data.raw_url) { + const downloadUrl = fileResponse.data.raw_url; + console.log('[OpenList Refresh] 下载 URL:', downloadUrl); + + const contentResponse = await fetch(downloadUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': '*/*', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + }, + }); + + console.log('[OpenList Refresh] fetch 响应:', { + status: contentResponse.status, + ok: contentResponse.ok, + }); + + if (!contentResponse.ok) { + throw new Error(`下载失败: ${contentResponse.status}`); + } + + const content = await contentResponse.text(); + console.log('[OpenList Refresh] 文件内容:', { + length: content.length, + preview: content.substring(0, 300), + }); + + existingMetaInfo = JSON.parse(content); + console.log('[OpenList Refresh] 读取到现有数据:', { + hasfolders: !!existingMetaInfo?.folders, + foldersType: typeof existingMetaInfo?.folders, + videoCount: Object.keys(existingMetaInfo?.folders || {}).length, + }); + } + } catch (error) { + console.error('[OpenList Refresh] 读取 metainfo.json 失败:', error); + console.log('[OpenList Refresh] 将创建新文件'); + } + } else { + console.log('[OpenList Refresh] 使用缓存的 metainfo,视频数:', Object.keys(existingMetaInfo.folders).length); + } + + const metaInfo: MetaInfo = existingMetaInfo || { + folders: {}, + last_refresh: Date.now(), + }; + + // 确保 folders 对象存在 + if (!metaInfo.folders || typeof metaInfo.folders !== 'object') { + console.warn('[OpenList Refresh] metaInfo.folders 无效,重新初始化'); + metaInfo.folders = {}; + } + + console.log('[OpenList Refresh] metaInfo 初始化完成:', { + hasfolders: !!metaInfo.folders, + foldersType: typeof metaInfo.folders, + videoCount: Object.keys(metaInfo.folders).length, + }); + + // 2. 列出根目录下的所有文件夹 + const listResponse = await client.listDirectory(rootPath); + + if (listResponse.code !== 200) { + return NextResponse.json( + { error: 'OpenList 列表获取失败' }, + { status: 500 } + ); + } + + const folders = listResponse.data.content.filter((item) => item.is_dir); + + console.log('[OpenList Refresh] 找到文件夹:', { + total: folders.length, + names: folders.map(f => f.name), + }); + + // 3. 遍历文件夹,搜索 TMDB + let newCount = 0; + let errorCount = 0; + + for (const folder of folders) { + console.log('[OpenList Refresh] 处理文件夹:', folder.name); + + // 跳过已搜索过的文件夹 + if (metaInfo.folders[folder.name]) { + console.log('[OpenList Refresh] 跳过已存在的文件夹:', folder.name); + continue; + } + + try { + console.log('[OpenList Refresh] 搜索 TMDB:', folder.name); + // 搜索 TMDB + const searchResult = await searchTMDB( + tmdbApiKey, + folder.name, + tmdbProxy + ); + + console.log('[OpenList Refresh] TMDB 搜索结果:', { + folder: folder.name, + code: searchResult.code, + hasResult: !!searchResult.result, + }); + + if (searchResult.code === 200 && searchResult.result) { + const result = searchResult.result; + + metaInfo.folders[folder.name] = { + tmdb_id: result.id, + title: result.title || result.name || folder.name, + poster_path: result.poster_path, + release_date: result.release_date || result.first_air_date || '', + overview: result.overview, + vote_average: result.vote_average, + media_type: result.media_type, + last_updated: Date.now(), + }; + + console.log('[OpenList Refresh] 添加成功:', { + folder: folder.name, + title: metaInfo.folders[folder.name].title, + }); + + newCount++; + } else { + console.warn(`[OpenList Refresh] TMDB 搜索失败: ${folder.name}`); + errorCount++; + } + + // 避免请求过快 + await new Promise((resolve) => setTimeout(resolve, 300)); + } catch (error) { + console.error(`[OpenList Refresh] 处理文件夹失败: ${folder.name}`, error); + errorCount++; + } + } + + // 4. 更新 metainfo.json + metaInfo.last_refresh = Date.now(); + + const metainfoPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}metainfo.json`; + const metainfoContent = JSON.stringify(metaInfo, null, 2); + console.log('[OpenList Refresh] 上传 metainfo.json:', { + path: metainfoPath, + videoCount: Object.keys(metaInfo.folders).length, + contentLength: metainfoContent.length, + contentPreview: metainfoContent.substring(0, 300), + }); + + await client.uploadFile(metainfoPath, metainfoContent); + console.log('[OpenList Refresh] 上传成功'); + + // 验证上传:立即读取文件 + try { + console.log('[OpenList Refresh] 验证上传:读取文件'); + const verifyResponse = await client.getFile(metainfoPath); + if (verifyResponse.code === 200 && verifyResponse.data.raw_url) { + const downloadUrl = verifyResponse.data.raw_url; + const verifyContentResponse = await fetch(downloadUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': '*/*', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + }, + }); + const verifyContent = await verifyContentResponse.text(); + console.log('[OpenList Refresh] 验证读取成功:', { + contentLength: verifyContent.length, + contentPreview: verifyContent.substring(0, 300), + }); + + // 尝试解析 + const verifyParsed = JSON.parse(verifyContent); + console.log('[OpenList Refresh] 验证解析成功:', { + hasfolders: !!verifyParsed.folders, + foldersType: typeof verifyParsed.folders, + videoCount: Object.keys(verifyParsed.folders || {}).length, + }); + } + } catch (verifyError) { + console.error('[OpenList Refresh] 验证失败:', verifyError); + } + + // 5. 更新缓存 + invalidateMetaInfoCache(rootPath); + setCachedMetaInfo(rootPath, metaInfo); + console.log('[OpenList Refresh] 缓存已更新'); + + // 6. 更新配置 + config.OpenListConfig!.LastRefreshTime = Date.now(); + config.OpenListConfig!.ResourceCount = Object.keys(metaInfo.folders).length; + await db.saveAdminConfig(config); + + return NextResponse.json({ + success: true, + total: folders.length, + new: newCount, + existing: Object.keys(metaInfo.folders).length - newCount, + errors: errorCount, + last_refresh: metaInfo.last_refresh, + }); + } catch (error) { + console.error('刷新私人影库失败:', error); + return NextResponse.json( + { error: '刷新失败', details: (error as Error).message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/search/ws/route.ts b/src/app/api/search/ws/route.ts index 4d7c675..8ee868b 100644 --- a/src/app/api/search/ws/route.ts +++ b/src/app/api/search/ws/route.ts @@ -33,6 +33,9 @@ export async function GET(request: NextRequest) { const config = await getConfig(); const apiSites = await getAvailableApiSites(authInfo.username); + // 检查是否配置了 OpenList + const hasOpenList = !!(config.OpenListConfig?.URL && config.OpenListConfig?.Token); + // 共享状态 let streamClosed = false; @@ -62,7 +65,7 @@ export async function GET(request: NextRequest) { const startEvent = `data: ${JSON.stringify({ type: 'start', query, - totalSources: apiSites.length, + totalSources: apiSites.length + (hasOpenList ? 1 : 0), timestamp: Date.now() })}\n\n`; @@ -74,6 +77,123 @@ export async function GET(request: NextRequest) { let completedSources = 0; const allResults: any[] = []; + // 搜索 OpenList(如果配置了) + if (hasOpenList) { + try { + const { getCachedMetaInfo } = await import('@/lib/openlist-cache'); + const { getTMDBImageUrl } = await import('@/lib/tmdb.search'); + const { OpenListClient } = await import('@/lib/openlist.client'); + + const rootPath = config.OpenListConfig!.RootPath || '/'; + let metaInfo = getCachedMetaInfo(rootPath); + + // 如果没有缓存,尝试从 OpenList 读取 + if (!metaInfo) { + try { + const client = new OpenListClient( + config.OpenListConfig!.URL, + config.OpenListConfig!.Token + ); + const metainfoPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}metainfo.json`; + const fileResponse = await client.getFile(metainfoPath); + + if (fileResponse.code === 200 && fileResponse.data.raw_url) { + const downloadUrl = fileResponse.data.raw_url; + const contentResponse = await fetch(downloadUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': '*/*', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + }, + }); + const content = await contentResponse.text(); + metaInfo = JSON.parse(content); + } + } catch (error) { + console.error('[Search WS] 读取 metainfo.json 失败:', error); + } + } + + if (metaInfo && metaInfo.folders) { + const openlistResults = Object.entries(metaInfo.folders) + .filter(([folderName, info]: [string, any]) => { + const matchFolder = folderName.toLowerCase().includes(query.toLowerCase()); + const matchTitle = info.title.toLowerCase().includes(query.toLowerCase()); + return matchFolder || matchTitle; + }) + .map(([folderName, info]: [string, any]) => ({ + id: folderName, + source: 'openlist', + source_name: '私人影库', + title: info.title, + poster: getTMDBImageUrl(info.poster_path), + episodes: [], + episodes_titles: [], + year: info.release_date.split('-')[0] || '', + desc: info.overview, + type_name: info.media_type === 'movie' ? '电影' : '电视剧', + douban_id: 0, + })); + + completedSources++; + + if (!streamClosed) { + const sourceEvent = `data: ${JSON.stringify({ + type: 'source_result', + source: 'openlist', + sourceName: '私人影库', + results: openlistResults, + timestamp: Date.now() + })}\n\n`; + + if (!safeEnqueue(encoder.encode(sourceEvent))) { + streamClosed = true; + return; + } + + if (openlistResults.length > 0) { + allResults.push(...openlistResults); + } + } + } else { + completedSources++; + + if (!streamClosed) { + const sourceEvent = `data: ${JSON.stringify({ + type: 'source_result', + source: 'openlist', + sourceName: '私人影库', + results: [], + timestamp: Date.now() + })}\n\n`; + + if (!safeEnqueue(encoder.encode(sourceEvent))) { + streamClosed = true; + return; + } + } + } + } catch (error) { + console.error('[Search WS] 搜索 OpenList 失败:', error); + completedSources++; + + if (!streamClosed) { + const errorEvent = `data: ${JSON.stringify({ + type: 'source_error', + source: 'openlist', + sourceName: '私人影库', + error: error instanceof Error ? error.message : '搜索失败', + timestamp: Date.now() + })}\n\n`; + + if (!safeEnqueue(encoder.encode(errorEvent))) { + streamClosed = true; + return; + } + } + } + } + // 为每个源创建搜索 Promise const searchPromises = apiSites.map(async (site) => { try { @@ -141,7 +261,7 @@ export async function GET(request: NextRequest) { } // 检查是否所有源都已完成 - if (completedSources === apiSites.length) { + if (completedSources === apiSites.length + (hasOpenList ? 1 : 0)) { if (!streamClosed) { // 发送最终完成事件 const completeEvent = `data: ${JSON.stringify({ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3098d77..0918ead 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -62,6 +62,7 @@ export default async function RootLayout({ let fluidSearch = process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false'; let enableComments = false; let tmdbApiKey = ''; + let openListEnabled = false; let customCategories = [] as { name: string; type: 'movie' | 'tv'; @@ -87,6 +88,10 @@ export default async function RootLayout({ fluidSearch = config.SiteConfig.FluidSearch; enableComments = config.SiteConfig.EnableComments; tmdbApiKey = config.SiteConfig.TMDBApiKey || ''; + // 检查是否配置了 OpenList + openListEnabled = !!( + config.OpenListConfig?.URL && config.OpenListConfig?.Token + ); } // 将运行时配置注入到全局 window 对象,供客户端在运行时读取 @@ -103,6 +108,7 @@ export default async function RootLayout({ ENABLE_TVBOX_SUBSCRIBE: process.env.ENABLE_TVBOX_SUBSCRIBE === 'true', ENABLE_OFFLINE_DOWNLOAD: process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true', VOICE_CHAT_STRATEGY: process.env.NEXT_PUBLIC_VOICE_CHAT_STRATEGY || 'webrtc-fallback', + OPENLIST_ENABLED: openListEnabled, }; return ( diff --git a/src/app/private-library/page.tsx b/src/app/private-library/page.tsx new file mode 100644 index 0000000..3f5b124 --- /dev/null +++ b/src/app/private-library/page.tsx @@ -0,0 +1,143 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, no-console */ + +'use client'; + +import { useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; + +import PageLayout from '@/components/PageLayout'; +import VideoCard from '@/components/VideoCard'; + +interface Video { + id: string; + folder: string; + title: string; + poster: string; + releaseDate: string; + overview: string; + voteAverage: number; + mediaType: 'movie' | 'tv'; +} + +export default function PrivateLibraryPage() { + const router = useRouter(); + const [videos, setVideos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const pageSize = 20; + + useEffect(() => { + fetchVideos(); + }, [page]); + + const fetchVideos = async () => { + try { + setLoading(true); + const response = await fetch( + `/api/openlist/list?page=${page}&pageSize=${pageSize}` + ); + + if (!response.ok) { + throw new Error('获取视频列表失败'); + } + + const data = await response.json(); + + if (data.error) { + setError(data.error); + setVideos([]); + } else { + setVideos(data.list || []); + setTotalPages(data.totalPages || 1); + } + } catch (err) { + console.error('获取视频列表失败:', err); + setError('获取视频列表失败'); + setVideos([]); + } finally { + setLoading(false); + } + }; + + const handleVideoClick = (video: Video) => { + // 跳转到播放页面 + router.push(`/play?source=openlist&id=${encodeURIComponent(video.folder)}`); + }; + + return ( + +
+

+ 私人影库 +

+ + {error && ( +
+

{error}

+
+ )} + + {loading ? ( +
+ {Array.from({ length: pageSize }).map((_, index) => ( +
+ ))} +
+ ) : videos.length === 0 ? ( +
+

+ 暂无视频,请在管理面板配置 OpenList 并刷新 +

+
+ ) : ( + <> +
+ {videos.map((video) => ( + + ))} +
+ + {/* 分页 */} + {totalPages > 1 && ( +
+ + + + 第 {page} / {totalPages} 页 + + + +
+ )} + + )} +
+ + ); +} diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx index 182ae2b..9a0b4d9 100644 --- a/src/components/EpisodeSelector.tsx +++ b/src/components/EpisodeSelector.tsx @@ -635,8 +635,8 @@ const EpisodeSelector: React.FC = ({ if (!title) { return episodeNumber; } - // 如果匹配"第X集"、"第X话"、"X集"、"X话"格式,提取中间的数字 - const match = title.match(/(?:第)?(\d+)(?:集|话)/); + // 如果匹配"第X集"、"第X话"、"X集"、"X话"格式,提取中间的数字(支持小数) + const match = title.match(/(?:第)?(\d+(?:\.\d+)?)(?:集|话)/); if (match) { return match[1]; } diff --git a/src/components/MobileBottomNav.tsx b/src/components/MobileBottomNav.tsx index 3b15fe3..ed74e9c 100644 --- a/src/components/MobileBottomNav.tsx +++ b/src/components/MobileBottomNav.tsx @@ -2,7 +2,7 @@ 'use client'; -import { Cat, Clover, Film, Home, Radio, Star, Tv, Users } from 'lucide-react'; +import { Cat, Clover, Film, FolderOpen, Home, Radio, Star, Tv, Users } from 'lucide-react'; import Link from 'next/link'; import { usePathname, useSearchParams } from 'next/navigation'; import { useEffect, useState } from 'react'; @@ -90,6 +90,15 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => { }, ]; + // 如果配置了 OpenList,添加私人影库入口 + if (runtimeConfig?.OPENLIST_ENABLED) { + items.push({ + icon: FolderOpen, + label: '私人影库', + href: '/private-library', + }); + } + // 如果启用观影室,添加观影室入口 if (watchRoomContext?.isEnabled) { items.push({ diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 12552e4..78a6d4a 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -2,7 +2,7 @@ 'use client'; -import { Cat, Clover, Film, Home, Menu, Radio, Search, Star, Tv, Users } from 'lucide-react'; +import { Cat, Clover, Film, FolderOpen, Home, Menu, Radio, Search, Star, Tv, Users } from 'lucide-react'; import Link from 'next/link'; import { usePathname, useSearchParams } from 'next/navigation'; import { @@ -179,6 +179,15 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => { }, ]; + // 如果配置了 OpenList,添加私人影库入口 + if (runtimeConfig?.OPENLIST_ENABLED) { + items.push({ + icon: FolderOpen, + label: '私人影库', + href: '/private-library', + }); + } + // 如果启用观影室,添加观影室入口 if (watchRoomContext?.isEnabled) { items.push({ diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts index 609939f..304b54d 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -91,6 +91,13 @@ export interface AdminConfig { cacheMinutes: number; // 缓存时间(分钟) cacheVersion: number; // CSS版本号(用于缓存控制) }; + OpenListConfig?: { + URL: string; // OpenList 服务器地址 + Token: string; // 认证 Token + RootPath: string; // 根目录路径,默认 "/" + LastRefreshTime?: number; // 上次刷新时间戳 + ResourceCount?: number; // 资源数量 + }; } export interface AdminConfigResult { diff --git a/src/lib/openlist-cache.ts b/src/lib/openlist-cache.ts new file mode 100644 index 0000000..d80182e --- /dev/null +++ b/src/lib/openlist-cache.ts @@ -0,0 +1,96 @@ +// metainfo.json 缓存 (7天) +interface MetaInfoCacheEntry { + expiresAt: number; + data: MetaInfo; +} + +// videoinfo.json 缓存 (1天) +interface VideoInfoCacheEntry { + expiresAt: number; + data: VideoInfo; +} + +const METAINFO_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7天 +const VIDEOINFO_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1天 + +const METAINFO_CACHE: Map = new Map(); +const VIDEOINFO_CACHE: Map = new Map(); + +export interface MetaInfo { + folders: { + [folderName: string]: { + tmdb_id: number; + title: string; + poster_path: string | null; + release_date: string; + overview: string; + vote_average: number; + media_type: 'movie' | 'tv'; + last_updated: number; + }; + }; + last_refresh: number; +} + +export interface VideoInfo { + episodes: { + [fileName: string]: { + episode: number; + season?: number; + title?: string; + parsed_from: 'videoinfo' | 'filename'; + }; + }; + last_updated: number; +} + +// MetaInfo 缓存操作 +export function getCachedMetaInfo(rootPath: string): MetaInfo | null { + const entry = METAINFO_CACHE.get(rootPath); + if (!entry) return null; + + if (entry.expiresAt <= Date.now()) { + METAINFO_CACHE.delete(rootPath); + return null; + } + + return entry.data; +} + +export function setCachedMetaInfo(rootPath: string, data: MetaInfo): void { + METAINFO_CACHE.set(rootPath, { + expiresAt: Date.now() + METAINFO_CACHE_TTL_MS, + data, + }); +} + +export function invalidateMetaInfoCache(rootPath: string): void { + METAINFO_CACHE.delete(rootPath); +} + +// VideoInfo 缓存操作 +export function getCachedVideoInfo(folderPath: string): VideoInfo | null { + const entry = VIDEOINFO_CACHE.get(folderPath); + if (!entry) return null; + + if (entry.expiresAt <= Date.now()) { + VIDEOINFO_CACHE.delete(folderPath); + return null; + } + + return entry.data; +} + +export function setCachedVideoInfo( + folderPath: string, + data: VideoInfo +): void { + VIDEOINFO_CACHE.set(folderPath, { + expiresAt: Date.now() + VIDEOINFO_CACHE_TTL_MS, + data, + }); +} + +export function invalidateVideoInfoCache(folderPath: string): void { + VIDEOINFO_CACHE.delete(folderPath); +} diff --git a/src/lib/openlist.client.ts b/src/lib/openlist.client.ts new file mode 100644 index 0000000..4f468e2 --- /dev/null +++ b/src/lib/openlist.client.ts @@ -0,0 +1,125 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export interface OpenListFile { + name: string; + size: number; + is_dir: boolean; + modified: string; + sign?: string; // 临时下载签名 + raw_url?: string; // 完整下载链接 + thumb?: string; + type: number; + path?: string; +} + +export interface OpenListListResponse { + code: number; + message: string; + data: { + content: OpenListFile[]; + total: number; + readme: string; + write: boolean; + }; +} + +export interface OpenListGetResponse { + code: number; + message: string; + data: OpenListFile; +} + +export class OpenListClient { + constructor( + private baseURL: string, + private token: string + ) {} + + private getHeaders() { + return { + Authorization: this.token, // 不带 bearer + 'Content-Type': 'application/json', + }; + } + + // 列出目录 + async listDirectory( + path: string, + page = 1, + perPage = 100 + ): Promise { + const response = await fetch(`${this.baseURL}/api/fs/list`, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify({ + path, + password: '', + refresh: false, + page, + per_page: perPage, + }), + }); + + if (!response.ok) { + throw new Error(`OpenList API 错误: ${response.status}`); + } + + return response.json(); + } + + // 获取文件信息 + async getFile(path: string): Promise { + const response = await fetch(`${this.baseURL}/api/fs/get`, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify({ + path, + password: '', + }), + }); + + if (!response.ok) { + throw new Error(`OpenList API 错误: ${response.status}`); + } + + return response.json(); + } + + // 上传文件 + async uploadFile(path: string, content: string): Promise { + const response = await fetch(`${this.baseURL}/api/fs/put`, { + method: 'PUT', + headers: { + Authorization: this.token, + 'Content-Type': 'text/plain; charset=utf-8', + 'File-Path': encodeURIComponent(path), + 'As-Task': 'false', + }, + body: content, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`OpenList 上传失败: ${response.status} - ${errorText}`); + } + } + + // 删除文件 + async deleteFile(path: string): Promise { + const dir = path.substring(0, path.lastIndexOf('/')) || '/'; + const fileName = path.substring(path.lastIndexOf('/') + 1); + + const response = await fetch(`${this.baseURL}/api/fs/remove`, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify({ + names: [fileName], + dir: dir, + }), + }); + + if (!response.ok) { + throw new Error(`OpenList 删除失败: ${response.status}`); + } + } +} diff --git a/src/lib/tmdb.client.ts b/src/lib/tmdb.client.ts index 848af7c..5636a19 100644 --- a/src/lib/tmdb.client.ts +++ b/src/lib/tmdb.client.ts @@ -45,6 +45,29 @@ interface TMDBTVAiringTodayResponse { total_results: number; } +// 代理 agent 缓存,避免每次都创建新实例 +const proxyAgentCache = new Map>(); + +/** + * 获取或创建代理 agent(复用连接池) + */ +function getProxyAgent(proxy: string): HttpsProxyAgent { + if (!proxyAgentCache.has(proxy)) { + const agent = new HttpsProxyAgent(proxy, { + // 增加超时时间 + timeout: 30000, // 30秒 + // 保持连接活跃 + keepAlive: true, + keepAliveMsecs: 60000, // 60秒 + // 最大空闲连接数 + maxSockets: 10, + maxFreeSockets: 5, + }); + proxyAgentCache.set(proxy, agent); + } + return proxyAgentCache.get(proxy)!; +} + /** * 获取即将上映的电影 * @param apiKey - TMDB API Key @@ -65,7 +88,14 @@ export async function getTMDBUpcomingMovies( } const url = `https://api.themoviedb.org/3/movie/upcoming?api_key=${apiKey}&language=zh-CN&page=${page}®ion=${region}`; - const fetchOptions: any = proxy ? { agent: new HttpsProxyAgent(proxy) } : {}; + const fetchOptions: any = proxy + ? { + agent: getProxyAgent(proxy), + signal: AbortSignal.timeout(30000), + } + : { + signal: AbortSignal.timeout(15000), + }; const response = await fetch(url, fetchOptions); @@ -105,7 +135,14 @@ export async function getTMDBUpcomingTVShows( // 使用 on_the_air 接口获取正在播出的电视剧 const url = `https://api.themoviedb.org/3/tv/on_the_air?api_key=${apiKey}&language=zh-CN&page=${page}`; - const fetchOptions: any = proxy ? { agent: new HttpsProxyAgent(proxy) } : {}; + const fetchOptions: any = proxy + ? { + agent: getProxyAgent(proxy), + signal: AbortSignal.timeout(30000), + } + : { + signal: AbortSignal.timeout(15000), + }; const response = await fetch(url, fetchOptions); diff --git a/src/lib/tmdb.search.ts b/src/lib/tmdb.search.ts new file mode 100644 index 0000000..c226baa --- /dev/null +++ b/src/lib/tmdb.search.ts @@ -0,0 +1,111 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { HttpsProxyAgent } from 'https-proxy-agent'; + +export interface TMDBSearchResult { + id: number; + title?: string; // 电影 + name?: string; // 电视剧 + poster_path: string | null; + release_date?: string; + first_air_date?: string; + overview: string; + vote_average: number; + media_type: 'movie' | 'tv'; +} + +interface TMDBSearchResponse { + results: TMDBSearchResult[]; + page: number; + total_pages: number; + total_results: number; +} + +// 代理 agent 缓存,避免每次都创建新实例 +const proxyAgentCache = new Map>(); + +/** + * 获取或创建代理 agent(复用连接池) + */ +function getProxyAgent(proxy: string): HttpsProxyAgent { + if (!proxyAgentCache.has(proxy)) { + const agent = new HttpsProxyAgent(proxy, { + // 增加超时时间 + timeout: 30000, // 30秒 + // 保持连接活跃 + keepAlive: true, + keepAliveMsecs: 60000, // 60秒 + // 最大空闲连接数 + maxSockets: 10, + maxFreeSockets: 5, + }); + proxyAgentCache.set(proxy, agent); + } + return proxyAgentCache.get(proxy)!; +} + +/** + * 搜索 TMDB (电影+电视剧) + */ +export async function searchTMDB( + apiKey: string, + query: string, + proxy?: string +): Promise<{ code: number; result: TMDBSearchResult | null }> { + try { + if (!apiKey) { + return { code: 400, result: null }; + } + + // 使用 multi search 同时搜索电影和电视剧 + const url = `https://api.themoviedb.org/3/search/multi?api_key=${apiKey}&language=zh-CN&query=${encodeURIComponent(query)}&page=1`; + + const fetchOptions: any = proxy + ? { + agent: getProxyAgent(proxy), + // 设置请求超时(30秒) + signal: AbortSignal.timeout(30000), + } + : { + // 即使不用代理也设置超时 + signal: AbortSignal.timeout(15000), + }; + + const response = await fetch(url, fetchOptions); + + if (!response.ok) { + console.error('TMDB 搜索失败:', response.status, response.statusText); + return { code: response.status, result: null }; + } + + const data: TMDBSearchResponse = await response.json(); + + // 过滤出电影和电视剧,取第一个结果 + const validResults = data.results.filter( + (item) => item.media_type === 'movie' || item.media_type === 'tv' + ); + + if (validResults.length === 0) { + return { code: 404, result: null }; + } + + return { + code: 200, + result: validResults[0], + }; + } catch (error) { + console.error('TMDB 搜索异常:', error); + return { code: 500, result: null }; + } +} + +/** + * 获取 TMDB 图片完整 URL + */ +export function getTMDBImageUrl( + path: string | null, + size: string = 'w500' +): string { + if (!path) return ''; + return `https://image.tmdb.org/t/p/${size}${path}`; +} diff --git a/src/lib/video-parser.ts b/src/lib/video-parser.ts new file mode 100644 index 0000000..0f3d723 --- /dev/null +++ b/src/lib/video-parser.ts @@ -0,0 +1,57 @@ +import parseTorrentName from 'parse-torrent-name'; + +export interface ParsedVideoInfo { + episode?: number; + season?: number; + title?: string; +} + +/** + * 解析视频文件名 + */ +export function parseVideoFileName(fileName: string): ParsedVideoInfo { + try { + const parsed = parseTorrentName(fileName); + + // 如果 parse-torrent-name 成功解析出集数,直接返回 + if (parsed.episode) { + return { + episode: parsed.episode, + season: parsed.season, + title: parsed.title, + }; + } + } catch (error) { + console.error('parse-torrent-name 解析失败:', fileName, error); + } + + // 降级方案:使用多种正则模式提取集数 + // 按优先级排序:更具体的模式优先 + const patterns = [ + // S01E01, s01e01, S01E01.5 (支持小数) - 最具体 + /[Ss]\d+[Ee](\d+(?:\.\d+)?)/, + // [01], (01), [01.5], (01.5) (支持小数) - 很具体 + /[\[\(](\d+(?:\.\d+)?)[\]\)]/, + // E01, E1, e01, e1, E01.5 (支持小数) + /[Ee](\d+(?:\.\d+)?)/, + // 第01集, 第1集, 第01话, 第1话, 第1.5集 (支持小数) + /第(\d+(?:\.\d+)?)[集话]/, + // _01_, -01-, _01.5_, -01.5- (支持小数) + /[_\-](\d+(?:\.\d+)?)[_\-]/, + // 01.mp4, 001.mp4, 01.5.mp4 (纯数字开头,支持小数) - 最不具体 + /^(\d+(?:\.\d+)?)[^\d.]/, + ]; + + for (const pattern of patterns) { + const match = fileName.match(pattern); + if (match && match[1]) { + const episode = parseFloat(match[1]); + if (episode > 0 && episode < 10000) { // 合理的集数范围 + return { episode }; + } + } + } + + // 如果所有模式都失败,返回空对象(调用方会处理) + return {}; +}