tvbox支持私人影库播放

This commit is contained in:
mtvpls
2025-12-29 00:19:05 +08:00
parent 577d61d144
commit 0a9d952929
4 changed files with 430 additions and 18 deletions

View File

@@ -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_urlTVBox 会调用详情接口获取
}));
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: '私人影库',
},
],
});
}

View File

@@ -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');

View File

@@ -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(

View File

@@ -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).*)',
],
};