diff --git a/src/app/api/openlist/cms-proxy/[token]/route.ts b/src/app/api/openlist/cms-proxy/[token]/route.ts new file mode 100644 index 0000000..555054e --- /dev/null +++ b/src/app/api/openlist/cms-proxy/[token]/route.ts @@ -0,0 +1,372 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { getConfig } from '@/lib/config'; + +export const runtime = 'nodejs'; + +/** + * OpenList CMS 代理接口(动态路由) + * 将 OpenList 私人影库转换为 TVBox 兼容的 CMS API 格式 + * 路径格式:/api/openlist/cms-proxy/{token}?ac=videolist&... + */ +export async function GET( + request: NextRequest, + { params }: { params: { token: string } } +) { + const { searchParams } = new URL(request.url); + const ac = searchParams.get('ac'); + const wd = searchParams.get('wd'); // 搜索关键词 + const ids = searchParams.get('ids'); // 视频ID(即文件夹key) + + // 检查必要参数 + if (ac !== 'videolist' && ac !== 'list' && ac !== 'detail') { + return NextResponse.json( + { code: 400, msg: '不支持的操作' }, + { status: 400 } + ); + } + + // 验证 TVBox Token(从路径中获取) + const requestToken = params.token; + const subscribeToken = process.env.TVBOX_SUBSCRIBE_TOKEN; + + if (!subscribeToken || requestToken !== subscribeToken) { + return NextResponse.json( + { + code: 401, + msg: '无效的访问token', + page: 1, + pagecount: 0, + limit: 0, + total: 0, + list: [], + }, + { status: 401 } + ); + } + + try { + const config = await getConfig(); + const openListConfig = config.OpenListConfig; + + // 验证 OpenList 配置 + if ( + !openListConfig || + !openListConfig.Enabled || + !openListConfig.URL || + !openListConfig.Username || + !openListConfig.Password + ) { + return NextResponse.json({ + code: 0, + msg: 'OpenList 未配置或未启用', + page: 1, + pagecount: 0, + limit: 0, + total: 0, + list: [], + }); + } + + const rootPath = openListConfig.RootPath || '/'; + + // 读取元数据 + const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache'); + const { db } = await import('@/lib/db'); + + let metaInfo = getCachedMetaInfo(rootPath); + + if (!metaInfo) { + try { + const metainfoJson = await db.getGlobalValue('video.metainfo'); + if (metainfoJson) { + metaInfo = JSON.parse(metainfoJson); + if (metaInfo) { + setCachedMetaInfo(rootPath, metaInfo); + } + } + } catch (error) { + console.error('[OpenList CMS Proxy] 从数据库读取 metainfo 失败:', error); + } + } + + if (!metaInfo || !metaInfo.folders) { + return NextResponse.json({ + code: 0, + msg: '未找到元数据', + page: 1, + pagecount: 0, + limit: 0, + total: 0, + list: [], + }); + } + + // 搜索模式 + if (wd) { + // 如果是 detail 模式且有搜索词,返回第一个匹配结果的详情 + if (ac === 'detail') { + return await handleDetailBySearch(metaInfo, wd, openListConfig, rootPath, requestToken, request); + } + return await handleSearch(metaInfo, wd, request); + } + + // 详情模式 + if (ids || ac === 'detail') { + if (!ids) { + return NextResponse.json({ + code: 0, + msg: '缺少视频ID', + page: 1, + pagecount: 0, + limit: 0, + total: 0, + list: [], + }); + } + return await handleDetail(metaInfo, ids, openListConfig, rootPath, requestToken, request); + } + + // 列表模式(返回所有) + return await handleSearch(metaInfo, '', request); + } catch (error) { + console.error('[OpenList CMS Proxy] 错误:', error); + return NextResponse.json( + { + code: 500, + msg: (error as Error).message, + page: 1, + pagecount: 0, + limit: 0, + total: 0, + list: [], + }, + { status: 500 } + ); + } +} + +/** + * 处理搜索请求 + */ +async function handleSearch(metaInfo: any, query: string, request: NextRequest) { + const { getTMDBImageUrl } = await import('@/lib/tmdb.search'); + + const lowerQuery = query.toLowerCase(); + const results = Object.entries(metaInfo.folders) + .filter(([folderName, info]: [string, any]) => { + if (!query) return true; // 空查询返回所有 + const matchFolder = folderName.toLowerCase().includes(lowerQuery); + const matchTitle = info.title.toLowerCase().includes(lowerQuery); + return matchFolder || matchTitle; + }) + .map(([folderKey, info]: [string, any]) => ({ + vod_id: folderKey, + vod_name: info.title, + vod_pic: getTMDBImageUrl(info.poster_path), + vod_remarks: info.media_type === 'movie' ? '电影' : '剧集', + vod_year: info.release_date ? info.release_date.split('-')[0] : '', + vod_content: info.overview || '', + type_name: info.media_type === 'movie' ? '电影' : '电视剧', + vod_douban_id: 0, + vod_class: '', + // 不在搜索结果中返回 vod_play_url,TVBox 会调用详情接口获取 + })); + + return NextResponse.json({ + code: 1, + msg: '数据列表', + page: 1, + pagecount: 1, + limit: results.length, + total: results.length, + list: results, + }); +} + +/** + * 处理通过搜索关键词获取详情的请求 + */ +async function handleDetailBySearch( + metaInfo: any, + query: string, + openListConfig: any, + rootPath: string, + token: string, + request: NextRequest +) { + const lowerQuery = query.toLowerCase(); + + // 搜索匹配的第一个文件夹 + const matchedEntry = Object.entries(metaInfo.folders).find(([folderName, info]: [string, any]) => { + const matchFolder = folderName.toLowerCase().includes(lowerQuery); + const matchTitle = info.title.toLowerCase().includes(lowerQuery); + return matchFolder || matchTitle; + }); + + if (!matchedEntry) { + return NextResponse.json({ + code: 0, + msg: '未找到该视频', + page: 1, + pagecount: 0, + limit: 0, + total: 0, + list: [], + }); + } + + // 使用找到的 folderKey 调用详情处理函数 + const [folderKey] = matchedEntry; + return await handleDetail(metaInfo, folderKey, openListConfig, rootPath, token, request); +} + +/** + * 处理详情请求 + */ +async function handleDetail( + metaInfo: any, + folderKey: string, + openListConfig: any, + rootPath: string, + token: string, + request: NextRequest +) { + const { getTMDBImageUrl } = await import('@/lib/tmdb.search'); + + // 查找文件夹信息 + const folderMeta = metaInfo.folders?.[folderKey]; + if (!folderMeta) { + return NextResponse.json({ + code: 0, + msg: '未找到该视频', + page: 1, + pagecount: 0, + limit: 0, + total: 0, + list: [], + }); + } + + // 获取文件夹名称和路径 + const folderName = folderMeta.folderName; + const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folderName}`; + + // 调用 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) { + return NextResponse.json({ + code: 0, + msg: 'OpenList 列表获取失败', + page: 1, + pagecount: 0, + limit: 0, + total: 0, + list: [], + }); + } + + 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); + } + + // 构建播放链接 + // 获取当前请求的 baseUrl + const host = request.headers.get('host') || request.headers.get('x-forwarded-host'); + const proto = request.headers.get('x-forwarded-proto') || + (host?.includes('localhost') || host?.includes('127.0.0.1') ? 'http' : 'https'); + const baseUrl = process.env.SITE_BASE || `${proto}://${host}`; + + 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; + } + + // 生成播放链接,将 token 放在路径中 + const playUrl = `${baseUrl}/api/openlist/play/${encodeURIComponent(token)}?folder=${encodeURIComponent(folderName)}&fileName=${encodeURIComponent(file.name)}`; + + return { + fileName: file.name, + episode: episodeInfo.episode || 0, + season: episodeInfo.season, + title: displayTitle, + playUrl, + }; + }) + .sort((a, b) => a.episode !== b.episode ? a.episode - b.episode : a.fileName.localeCompare(b.fileName)); + + // 转换为 CMS vod_play_url 格式 + // 格式:第1集$url1#第2集$url2#第3集$url3 + const vodPlayUrl = episodes + .map(ep => `${ep.title}$${ep.playUrl}`) + .join('#'); + + return NextResponse.json({ + code: 1, + msg: '数据列表', + page: 1, + pagecount: 1, + limit: 1, + total: 1, + list: [ + { + vod_id: folderKey, + vod_name: folderMeta.title, + vod_pic: getTMDBImageUrl(folderMeta.poster_path), + vod_remarks: folderMeta.media_type === 'movie' ? '电影' : '剧集', + vod_year: folderMeta.release_date ? folderMeta.release_date.split('-')[0] : '', + vod_content: folderMeta.overview || '', + type_name: folderMeta.media_type === 'movie' ? '电影' : '电视剧', + vod_douban_id: 0, + vod_class: '', + vod_play_url: vodPlayUrl, + vod_play_from: '私人影库', + }, + ], + }); +} diff --git a/src/app/api/openlist/play/route.ts b/src/app/api/openlist/play/[token]/route.ts similarity index 77% rename from src/app/api/openlist/play/route.ts rename to src/app/api/openlist/play/[token]/route.ts index 95955b5..7b51566 100644 --- a/src/app/api/openlist/play/route.ts +++ b/src/app/api/openlist/play/[token]/route.ts @@ -9,18 +9,34 @@ import { OpenListClient } from '@/lib/openlist.client'; export const runtime = 'nodejs'; /** - * GET /api/openlist/play?folder=xxx&fileName=xxx + * GET /api/openlist/play/{token}?folder=xxx&fileName=xxx * 获取单个视频文件的播放链接(懒加载) * 返回重定向到真实播放 URL + * + * 权限验证:TVBox Token(路径参数) 或 用户登录(满足其一即可) */ -export async function GET(request: NextRequest) { +export async function GET( + request: NextRequest, + { params }: { params: { token: string } } +) { try { + const { searchParams } = new URL(request.url); + + // 双重验证:TVBox Token 或 用户登录 + const requestToken = params.token; + const subscribeToken = process.env.TVBOX_SUBSCRIBE_TOKEN; const authInfo = getAuthInfoFromCookie(request); - if (!authInfo || !authInfo.username) { + + // 验证 TVBox Token + const hasValidToken = subscribeToken && requestToken === subscribeToken; + // 验证用户登录 + const hasValidAuth = authInfo && authInfo.username; + + // 两者至少满足其一 + if (!hasValidToken && !hasValidAuth) { return NextResponse.json({ error: '未授权' }, { status: 401 }); } - const { searchParams } = new URL(request.url); const folderName = searchParams.get('folder'); const fileName = searchParams.get('fileName'); diff --git a/src/app/api/tvbox/subscribe/route.ts b/src/app/api/tvbox/subscribe/route.ts index be72f69..b0fdbfe 100644 --- a/src/app/api/tvbox/subscribe/route.ts +++ b/src/app/api/tvbox/subscribe/route.ts @@ -63,6 +63,26 @@ export async function GET(request: NextRequest) { console.log('TVBOX 订阅 baseUrl:', baseUrl, 'adFilter:', adFilter); + // 检查是否配置了 OpenList + const hasOpenList = !!( + config.OpenListConfig?.Enabled && + config.OpenListConfig?.URL && + config.OpenListConfig?.Username && + config.OpenListConfig?.Password + ); + + // 构建 OpenList 站点配置 + const openlistSites = hasOpenList ? [{ + key: 'openlist', + name: '私人影库', + type: 1, + api: `${baseUrl}/api/openlist/cms-proxy/${encodeURIComponent(subscribeToken)}`, + searchable: 1, + quickSearch: 1, + filterable: 1, + ext: '', + }] : []; + // 构建TVBOX订阅数据 const tvboxSubscription = { // 站点配置 @@ -70,19 +90,23 @@ export async function GET(request: NextRequest) { wallpaper: '', // 视频源站点 - 根据 adFilter 参数决定是否使用代理 - sites: apiSites.map(site => ({ - key: site.key, - name: site.name, - type: 1, - // 如果开启去广告,使用 CMS 代理;否则使用原始 API - api: adFilter - ? `${baseUrl}/api/cms-proxy?api=${encodeURIComponent(site.api)}` - : site.api, - searchable: 1, - quickSearch: 1, - filterable: 1, - ext: site.detail || '', - })), + // OpenList 源放在最前面 + sites: [ + ...openlistSites, + ...apiSites.map(site => ({ + key: site.key, + name: site.name, + type: 1, + // 如果开启去广告,使用 CMS 代理;否则使用原始 API + api: adFilter + ? `${baseUrl}/api/cms-proxy?api=${encodeURIComponent(site.api)}` + : site.api, + searchable: 1, + quickSearch: 1, + filterable: 1, + ext: site.detail || '', + })) + ], // 直播源 lives: await Promise.all( diff --git a/src/middleware.ts b/src/middleware.ts index 780439e..1dc9654 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -133,6 +133,6 @@ function shouldSkipAuth(pathname: string): boolean { // 配置middleware匹配规则 export const config = { matcher: [ - '/((?!_next/static|_next/image|favicon.ico|login|register|oidc-register|warning|api/login|api/register|api/logout|api/auth/oidc|api/cron|api/server-config|api/proxy-m3u8|api/cms-proxy|api/tvbox/subscribe|api/theme/css).*)', + '/((?!_next/static|_next/image|favicon.ico|login|register|oidc-register|warning|api/login|api/register|api/logout|api/auth/oidc|api/cron|api/server-config|api/proxy-m3u8|api/cms-proxy|api/tvbox/subscribe|api/theme/css|api/openlist/cms-proxy|api/openlist/play).*)', ], };