Merge branch 'dev'

This commit is contained in:
mtvpls
2026-03-20 20:22:43 +08:00
39 changed files with 5102 additions and 2592 deletions

View File

@@ -1,3 +1,27 @@
## [215.0.0] - 2026-03-20
### Added
- 增加主动恢复进度按钮
- 新增播放记录面板
- 弹幕搜索面板标题增加tooltip
- 影视搜索新增列表视图
- pansou增加重试按钮
- 新增ai评论生成
- 增加一键render部署
- 电视直播增加三种代理模式
### Changed
- 优化直链播放m3u8体验
- 搜索页面海量数据下使用虚拟滚动提高性能
- 优化emby代理内存泄漏问题
- 电视直播代理控制权从用户端改为管理端
- 获取视频源详情不再依赖title
### Fixed
- 修复search页面僵尸历史记录tag
- 修复弹幕搜索框挤压
- 修复搜索页面加载条显示顺序错误
- 修复继续观看渐进式加载的一些问题
## [214.1.0] - 2026-03-10
### Changed
- emby兼容jellyfin

View File

@@ -90,10 +90,14 @@
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/mtvpls/MoonTVPlus)
**一键部署到zeabur**
**一键部署到 Zeabur**
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/SCHCAY/deploy)
**一键部署到 Render**
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/mtvpls/MoonTVPlus)
### Cloudflare Workers 部署(通过 GitHub Actions

View File

@@ -1,2 +1,2 @@
214.1.0
215.0.0

16
render.yaml Normal file
View File

@@ -0,0 +1,16 @@
services:
- type: web
name: moontvplus
runtime: image
plan: free
image:
url: ghcr.io/mtvpls/moontvplus:latest
envVars:
- key: USERNAME
sync: false
- key: PASSWORD
sync: false
- key: NEXT_PUBLIC_SITE_NAME
value: MoonTVPlus
- key: CRON_PASSWORD
value: mtvpls

View File

@@ -380,6 +380,7 @@ interface LiveDataSource {
channelNumber?: number;
disabled?: boolean;
from: 'config' | 'custom';
proxyMode?: 'full' | 'm3u8-only' | 'direct'; // 代理模式
}
// 自定义分类数据类型
@@ -10327,6 +10328,7 @@ const AIConfigComponent = ({
const [enableHomepageEntry, setEnableHomepageEntry] = useState(true);
const [enableVideoCardEntry, setEnableVideoCardEntry] = useState(true);
const [enablePlayPageEntry, setEnablePlayPageEntry] = useState(true);
const [enableAIComments, setEnableAIComments] = useState(false);
// 权限控制
const [allowRegularUsers, setAllowRegularUsers] = useState(true);
@@ -10357,6 +10359,7 @@ const AIConfigComponent = ({
setEnableHomepageEntry(config.AIConfig.EnableHomepageEntry !== false);
setEnableVideoCardEntry(config.AIConfig.EnableVideoCardEntry !== false);
setEnablePlayPageEntry(config.AIConfig.EnablePlayPageEntry !== false);
setEnableAIComments(config.AIConfig.EnableAIComments || false);
setAllowRegularUsers(config.AIConfig.AllowRegularUsers !== false);
setTemperature(config.AIConfig.Temperature ?? 0.7);
setMaxTokens(config.AIConfig.MaxTokens ?? 1000);
@@ -10390,6 +10393,7 @@ const AIConfigComponent = ({
EnableHomepageEntry: enableHomepageEntry,
EnableVideoCardEntry: enableVideoCardEntry,
EnablePlayPageEntry: enablePlayPageEntry,
EnableAIComments: enableAIComments,
AllowRegularUsers: allowRegularUsers,
Temperature: temperature,
MaxTokens: maxTokens,
@@ -10659,6 +10663,7 @@ const AIConfigComponent = ({
{ key: 'homepage', label: '首页入口', desc: '在首页显示AI问片入口', state: enableHomepageEntry, setState: setEnableHomepageEntry },
{ key: 'videocard', label: '视频卡片入口', desc: '在视频卡片菜单中显示AI问片选项', state: enableVideoCardEntry, setState: setEnableVideoCardEntry },
{ key: 'playpage', label: '播放页入口', desc: '在视频播放页显示AI问片功能', state: enablePlayPageEntry, setState: setEnablePlayPageEntry },
{ key: 'aicomments', label: 'AI评论功能', desc: '在播放页生成AI评论独立于豆瓣评论', state: enableAIComments, setState: setEnableAIComments },
].map((item) => (
<div key={item.key} className='flex items-center justify-between py-2'>
<div>
@@ -10932,6 +10937,53 @@ const LiveSourceConfig = ({
});
};
const handleSetProxyMode = (key: string, mode: 'full' | 'm3u8-only' | 'direct') => {
withLoading(`setLiveProxyMode_${key}`, async () => {
// 保存旧值用于回滚
const oldMode = liveSources.find((s) => s.key === key)?.proxyMode;
// 乐观更新本地状态
setLiveSources((prev) =>
prev.map((s) =>
s.key === key ? { ...s, proxyMode: mode } : s
)
);
try {
const response = await fetch('/api/admin/live', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'set_proxy_mode',
key,
proxyMode: mode,
}),
});
if (!response.ok) {
throw new Error('设置代理模式失败');
}
// 成功后刷新配置
await refreshConfig();
} catch (error) {
// 失败时回滚本地状态
setLiveSources((prev) =>
prev.map((s) =>
s.key === key ? { ...s, proxyMode: oldMode } : s
)
);
showError(
error instanceof Error ? error.message : '设置代理模式失败',
showAlert
);
throw error;
}
}).catch(() => {
console.error('操作失败', 'set_proxy_mode', key);
});
};
const handleDelete = (key: string) => {
withLoading(`deleteLiveSource_${key}`, () =>
callLiveSourceApi({ action: 'delete', key })
@@ -11108,6 +11160,24 @@ const LiveSourceConfig = ({
{!liveSource.disabled ? '启用中' : '已禁用'}
</span>
</td>
<td className='px-6 py-4 whitespace-nowrap'>
<select
value={liveSource.proxyMode || 'full'}
onChange={(e) => {
handleSetProxyMode(liveSource.key, e.target.value as 'full' | 'm3u8-only' | 'direct');
}}
disabled={isLoading(`setLiveProxyMode_${liveSource.key}`)}
className={`px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 ${
isLoading(`setLiveProxyMode_${liveSource.key}`)
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer'
}`}
>
<option value='full'></option>
<option value='m3u8-only'>m3u8</option>
<option value='direct'></option>
</select>
</td>
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
<button
onClick={() => handleToggleEnable(liveSource.key)}
@@ -11415,6 +11485,9 @@ const LiveSourceConfig = ({
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>

View File

@@ -57,6 +57,7 @@ export async function POST(request: NextRequest) {
EnableHomepageEntry,
EnableVideoCardEntry,
EnablePlayPageEntry,
EnableAIComments,
AllowRegularUsers,
Temperature,
MaxTokens,
@@ -93,6 +94,7 @@ export async function POST(request: NextRequest) {
EnableHomepageEntry: boolean;
EnableVideoCardEntry: boolean;
EnablePlayPageEntry: boolean;
EnableAIComments: boolean;
AllowRegularUsers: boolean;
Temperature?: number;
MaxTokens?: number;
@@ -132,6 +134,7 @@ export async function POST(request: NextRequest) {
typeof EnableHomepageEntry !== 'boolean' ||
typeof EnableVideoCardEntry !== 'boolean' ||
typeof EnablePlayPageEntry !== 'boolean' ||
typeof EnableAIComments !== 'boolean' ||
typeof AllowRegularUsers !== 'boolean' ||
(Temperature !== undefined && typeof Temperature !== 'number') ||
(MaxTokens !== undefined && typeof MaxTokens !== 'number') ||
@@ -181,6 +184,7 @@ export async function POST(request: NextRequest) {
EnableHomepageEntry,
EnableVideoCardEntry,
EnablePlayPageEntry,
EnableAIComments,
AllowRegularUsers,
Temperature,
MaxTokens,

View File

@@ -23,7 +23,7 @@ export async function POST(request: NextRequest) {
}
const body = await request.json();
const { action, key, name, url, ua, epg } = body;
const { action, key, name, url, ua, epg, proxyMode } = body;
if (!config) {
return NextResponse.json({ error: '配置不存在' }, { status: 404 });
@@ -153,6 +153,18 @@ export async function POST(request: NextRequest) {
config.LiveConfig = sortedLiveConfig;
break;
case 'set_proxy_mode':
// 设置代理模式
const setProxySource = config.LiveConfig.find((l) => l.key === key);
if (!setProxySource) {
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
}
if (!proxyMode || !['full', 'm3u8-only', 'direct'].includes(proxyMode)) {
return NextResponse.json({ error: '无效的代理模式' }, { status: 400 });
}
setProxySource.proxyMode = proxyMode as 'full' | 'm3u8-only' | 'direct';
break;
default:
return NextResponse.json({ error: '未知操作' }, { status: 400 });
}

View File

@@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from 'next/server';
import { generateAIComments, AIComment } from '@/lib/ai-comment-generator';
import { getConfig } from '@/lib/config';
export const runtime = 'nodejs';
interface AICommentsResponse {
comments: AIComment[];
total: number;
movieName: string;
isAiGenerated: true;
generatedAt: string;
}
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const movieName = searchParams.get('name');
const movieInfo = searchParams.get('info');
const count = parseInt(searchParams.get('count') || '10');
// 参数验证
if (!movieName) {
return NextResponse.json(
{ error: '缺少影片名称参数' },
{ status: 400 }
);
}
if (count < 1 || count > 50) {
return NextResponse.json(
{ error: '评论数量必须在1-50之间' },
{ status: 400 }
);
}
// 读取AI配置
const config = await getConfig();
const aiConfig = config.AIConfig;
// 检查AI功能是否启用
if (!aiConfig?.Enabled) {
return NextResponse.json(
{ error: 'AI功能未启用' },
{ status: 403 }
);
}
// 检查AI评论功能是否启用
if (!aiConfig?.EnableAIComments) {
return NextResponse.json(
{ error: 'AI评论功能未启用' },
{ status: 403 }
);
}
// 检查必要的配置
if (!aiConfig.CustomApiKey || !aiConfig.CustomBaseURL || !aiConfig.CustomModel) {
return NextResponse.json(
{ error: 'AI配置不完整请在管理面板配置' },
{ status: 500 }
);
}
// 生成AI评论
const comments = await generateAIComments({
movieName,
movieInfo: movieInfo || undefined,
count,
aiConfig: {
CustomApiKey: aiConfig.CustomApiKey,
CustomBaseURL: aiConfig.CustomBaseURL,
CustomModel: aiConfig.CustomModel,
Temperature: aiConfig.Temperature,
MaxTokens: aiConfig.MaxTokens,
EnableWebSearch: aiConfig.EnableWebSearch,
WebSearchProvider: aiConfig.WebSearchProvider,
TavilyApiKey: aiConfig.TavilyApiKey,
SerperApiKey: aiConfig.SerperApiKey,
SerpApiKey: aiConfig.SerpApiKey,
},
});
// 返回结果
const response: AICommentsResponse = {
comments,
total: comments.length,
movieName,
isAiGenerated: true,
generatedAt: new Date().toISOString(),
};
return NextResponse.json(response);
} catch (error) {
console.error('AI评论生成失败:', error);
// 返回友好的错误信息
const errorMessage = error instanceof Error ? error.message : 'AI评论生成失败';
return NextResponse.json(
{
error: errorMessage,
details: process.env.NODE_ENV === 'development' ? String(error) : undefined
},
{ status: 500 }
);
}
}

View File

@@ -84,10 +84,19 @@ export async function GET(
'User-Agent': client.getUserAgent(),
};
// 请求图片
const imageResponse = await fetch(imageUrl, {
headers: requestHeaders,
});
// 创建 AbortController 用于超时控制
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), 20000); // 20秒超时
try {
// 请求图片
const imageResponse = await fetch(imageUrl, {
headers: requestHeaders,
signal: abortController.signal,
});
// 清除超时定时器
clearTimeout(timeoutId);
if (!imageResponse.ok) {
console.error('[Emby Image] 获取图片失败:', {
@@ -123,6 +132,19 @@ export async function GET(
status: imageResponse.status,
headers,
});
} catch (error) {
// 清除超时定时器
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
console.error('[Emby Image] 请求超时');
return NextResponse.json(
{ error: '请求超时' },
{ status: 504 }
);
}
throw error;
}
} catch (error) {
console.error('[Emby Image] 错误:', error);
return NextResponse.json(

View File

@@ -92,22 +92,42 @@ export async function GET(
requestHeaders['Range'] = rangeHeader;
}
// 流式代理视频内容
let videoResponse = await fetch(embyStreamUrl, {
headers: requestHeaders,
});
// 创建 AbortController 用于超时控制
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), 300000); // 5分钟超时
// 如果返回 401尝试重新认证并重试
if (videoResponse.status === 401) {
console.log('[Emby Play] 收到 401 错误,尝试重新认证');
const { embyManager } = await import('@/lib/emby-manager');
embyManager.clearCache();
client = await getEmbyClient(embyKey);
embyStreamUrl = await client.getStreamUrl(itemId, true, true);
videoResponse = await fetch(embyStreamUrl, {
try {
// 流式代理视频内容
let videoResponse = await fetch(embyStreamUrl, {
headers: requestHeaders,
signal: abortController.signal,
});
}
// 如果返回 401尝试重新认证并重试
if (videoResponse.status === 401) {
console.log('[Emby Play] 收到 401 错误,尝试重新认证');
const { embyManager } = await import('@/lib/emby-manager');
embyManager.clearCache();
client = await getEmbyClient(embyKey);
embyStreamUrl = await client.getStreamUrl(itemId, true, true);
// 重置超时
clearTimeout(timeoutId);
const retryAbortController = new AbortController();
const retryTimeoutId = setTimeout(() => retryAbortController.abort(), 300000);
try {
videoResponse = await fetch(embyStreamUrl, {
headers: requestHeaders,
signal: retryAbortController.signal,
});
} finally {
clearTimeout(retryTimeoutId);
}
}
// 清除超时定时器
clearTimeout(timeoutId);
if (!videoResponse.ok) {
console.error('[Emby Play] 获取视频流失败:', {
@@ -147,11 +167,64 @@ export async function GET(
// 使用 URL 中的文件名
headers.set('Content-Disposition', `inline; filename="${params.filename}"`);
// 流式返回视频内容,不等待下载完成
return new NextResponse(videoResponse.body, {
// 创建一个可以被中断的流
const { readable, writable } = new TransformStream();
const reader = videoResponse.body?.getReader();
if (!reader) {
return NextResponse.json(
{ error: '无法读取视频流' },
{ status: 500 }
);
}
// 异步管道传输,确保在客户端断开时清理资源
(async () => {
const writer = writable.getWriter();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
await writer.write(value);
}
} catch (error) {
// 客户端断开连接或其他错误
console.log('[Emby Play] 流传输中断:', error instanceof Error ? error.message : 'Unknown error');
// 取消上游 fetch停止继续下载
try {
await reader.cancel();
} catch (e) {
// 忽略取消错误
}
} finally {
// 确保资源被释放
try {
reader.releaseLock();
await writer.close();
} catch (e) {
// 忽略关闭错误
}
}
})();
// 流式返回视频内容
return new NextResponse(readable, {
status: videoResponse.status,
headers,
});
} catch (error) {
// 清除超时定时器
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
console.error('[Emby Play] 请求超时');
return NextResponse.json(
{ error: '请求超时' },
{ status: 504 }
);
}
throw error;
}
} catch (error) {
console.error('[Emby Play] 错误:', error);
return NextResponse.json(

View File

@@ -1,15 +1,18 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
export const runtime = 'nodejs';
export const maxDuration = 60; // 设置最大执行时间为 60 秒
/**
* M3U8 代理接口
* 用于外部播放器访问,会执行去广告逻辑并处理相对链接
* GET /api/proxy-m3u8?url=<原始m3u8地址>&source=<播放源>&token=<鉴权token>
*/
export async function GET(request: Request) {
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const m3u8Url = searchParams.get('url');
@@ -34,12 +37,40 @@ export async function GET(request: Request) {
);
}
const DIRECT_PLAY_SOURCE = 'directplay';
// 安全校验:防 SSRF / 域名重绑定,只允许合法的公网 URL。对所有经过 proxy-m3u8 的请求强制校验,不仅限于 directplay
const isSafeUrl = await validateProxyUrlServerSide(m3u8Url);
if (!isSafeUrl) {
return NextResponse.json(
{ error: 'Proxy request to local or invalid network is forbidden' },
{ status: 403 }
);
}
// 获取当前请求的 origin
// 优先级SITE_BASE 环境变量 > 从请求头构建
let origin = process.env.SITE_BASE;
if (!origin) {
const requestUrl = new URL(request.url);
origin = `${requestUrl.protocol}//${requestUrl.host}`;
// 从请求头中获取 Host 和协议
let host = request.headers.get('host') || request.headers.get('x-forwarded-host');
// 安全校验:防 Host 头注入漏洞 (要求仅包含合法域名或 IP 格式字符)
if (host && !/^[a-zA-Z0-9.-]+(:\d+)?$/.test(host)) {
host = null;
}
// Fallback如果以上 Header 无效或未提供,回退到 request.url 获取
if (!host) {
try {
host = new URL(request.url).host;
} catch {
return NextResponse.json({ error: 'Invalid Request Host' }, { status: 400 });
}
}
const proto = request.headers.get('x-forwarded-proto') ||
(host.includes('localhost') || host.includes('127.0.0.1') ? 'http' : 'https');
origin = `${proto}://${host}`;
}
// 获取原始 m3u8 内容
@@ -61,8 +92,58 @@ export async function GET(request: Request) {
);
}
// 后端 MIME Sniffing: 防御伪装成 m3u8 的大文件二进制流
// 使用白名单策略:只有明确属于文本/m3u8 类型的才放行解析
const contentType = (response.headers.get('content-type') || '').toLowerCase();
const isTextType = (
contentType === '' || // 无 Content-Type 时保守放行(后续有内容校验兜底)
contentType.includes('application/vnd.apple.mpegurl') || // 标准 m3u8
contentType.includes('application/x-mpegurl') || // 兼容 m3u8
contentType.includes('audio/mpegurl') || // 兼容 m3u8
contentType.includes('text/') || // text/plain 等
contentType.includes('application/json') // 部分 API 返回 JSON 格式的错误
);
if (!isTextType) {
if (source === DIRECT_PLAY_SOURCE) {
console.log(`[Proxy-M3U8] 检测到非文本媒体流 (Content-Type: ${contentType}), 针对 directplay 直链代理模式,直接透传二进制流, URL: ${m3u8Url}`);
// 构造一个新的 Response 对象用于二进制直接透传,确保包含了支持跨域的 header
const newHeaders = new Headers(response.headers);
newHeaders.set('Access-Control-Allow-Origin', '*');
// 如果源站返回了跨站相关的禁止头,尽量移除它们
newHeaders.delete('X-Frame-Options');
newHeaders.delete('Content-Security-Policy');
return new NextResponse(response.body, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
});
}
console.warn(`[Proxy-M3U8] 拦截到非文本媒体流 (Content-Type: ${contentType}), 拒绝按文本解析, URL: ${m3u8Url}`);
return NextResponse.json(
{
error: 'Unsupported Media Type',
details: `The source returned Content-Type "${contentType}", which is not a text m3u8 playlist.`,
fallbackToDirect: true,
originalUrl: m3u8Url
},
{ status: 415, headers: { 'Access-Control-Allow-Origin': '*' } }
);
}
let m3u8Content = await response.text();
// 二次内容校验:即使 Content-Type 通过了白名单,检查实际内容是否为有效的 m3u8
// 有些服务器返回 text/plain 但实际内容是 HTML 错误页或其他格式
const trimmedContent = m3u8Content.trimStart();
if (trimmedContent.length > 0 && !trimmedContent.startsWith('#EXTM3U') && !trimmedContent.startsWith('#EXT')) {
console.warn(`[Proxy-M3U8] 内容校验失败:响应体不以 #EXTM3U 或 #EXT 开头, 可能非有效 m3u8, URL: ${m3u8Url}`);
// 不直接拒绝(可能是不规范但仍可播放的 m3u8仅打印警告继续处理
}
// 执行去广告逻辑
const config = await getConfig();
const customAdFilterCode = config.SiteConfig?.CustomAdFilterCode || '';
@@ -111,7 +192,10 @@ export async function GET(request: Request) {
}
/**
* 默认去广告规则
* 默认去广告规则(服务端版本)
* 注意:前端 page.tsx 中的 filterAdsFromM3U8 是客户端侧的去广告逻辑(用于直连模式下由 HLS.js 的自定义 loader 拦截)。
* 本函数用于代理模式下,在服务端对 m3u8 内容进行去广告处理后再返回给客户端。
* 两套逻辑需要保持同步更新。
*/
function filterAdsFromM3U8Default(type: string, m3u8Content: string): string {
if (!m3u8Content) return '';
@@ -167,7 +251,10 @@ function filterAdsFromM3U8Default(type: string, m3u8Content: string): string {
}
/**
* 将 m3u8 中的相对链接转换为绝对链接,并将子 m3u8 链接转为代理链接
* 将 m3u8 中的相对链接转换为绝对链接,并将子 m3u8 链接转为代理链接
* 此函数仅在代理模式下由服务端调用。
* - 子 m3u8 链接 → 指向 /api/proxy-m3u8递归代理
* - ts 分片/密钥 → directplay 模式指向 /api/proxy/vod/segment解决 CORS
*/
function resolveM3u8Links(m3u8Content: string, baseUrl: string, source: string, proxyOrigin: string, token: string): string {
const lines = m3u8Content.split('\n');
@@ -196,10 +283,15 @@ function resolveM3u8Links(m3u8Content: string, baseUrl: string, source: string,
} else {
keyUri = new URL(keyUri, baseDir).href;
}
// 替换原来的 URI
line = line.replace(/URI="[^"]+"/, `URI="${keyUri}"`);
}
// 直链播放模式:通过代理访问密钥,避免 CORS 问题
if (source === 'directplay') {
keyUri = `${proxyOrigin}/api/proxy/vod/segment?url=${encodeURIComponent(keyUri)}&source=directplay`;
}
// 替换原来的 URI
line = line.replace(/URI="[^"]+"/, `URI="${keyUri}"`);
}
resolvedLines.push(line);
continue;
@@ -240,6 +332,9 @@ function resolveM3u8Links(m3u8Content: string, baseUrl: string, source: string,
if (isM3u8) {
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : '';
url = `${proxyOrigin}/api/proxy-m3u8?url=${encodeURIComponent(url)}${source ? `&source=${encodeURIComponent(source)}` : ''}${tokenParam}`;
} else if (source === 'directplay') {
// 直链播放模式通过代理访问媒体分片ts/jpeg/png 等),避免 CORS 问题
url = `${proxyOrigin}/api/proxy/vod/segment?url=${encodeURIComponent(url)}&source=directplay`;
}
resolvedLines.push(url);

View File

@@ -110,6 +110,10 @@ function rewriteM3U8Content(content: string, baseUrl: string, req: Request, allo
const host = req.headers.get('host');
const proxyBase = `${protocol}://${host}/api/proxy`;
// 获取 moontv-source 参数
const reqUrl = new URL(req.url);
const source = reqUrl.searchParams.get('moontv-source') || '';
const lines = content.split('\n');
const rewrittenLines: string[] = [];
@@ -119,19 +123,19 @@ function rewriteM3U8Content(content: string, baseUrl: string, req: Request, allo
// 处理 TS 片段 URL 和其他媒体文件
if (line && !line.startsWith('#')) {
const resolvedUrl = resolveUrl(baseUrl, line);
const proxyUrl = allowCORS ? resolvedUrl : `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}`;
const proxyUrl = allowCORS ? resolvedUrl : `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}&moontv-source=${source}`;
rewrittenLines.push(proxyUrl);
continue;
}
// 处理 EXT-X-MAP 标签中的 URI
if (line.startsWith('#EXT-X-MAP:')) {
line = rewriteMapUri(line, baseUrl, proxyBase);
line = rewriteMapUri(line, baseUrl, proxyBase, allowCORS, source);
}
// 处理 EXT-X-KEY 标签中的 URI
if (line.startsWith('#EXT-X-KEY:')) {
line = rewriteKeyUri(line, baseUrl, proxyBase);
line = rewriteKeyUri(line, baseUrl, proxyBase, allowCORS, source);
}
// 处理嵌套的 M3U8 文件 (EXT-X-STREAM-INF)
@@ -143,7 +147,7 @@ function rewriteM3U8Content(content: string, baseUrl: string, req: Request, allo
const nextLine = lines[i].trim();
if (nextLine && !nextLine.startsWith('#')) {
const resolvedUrl = resolveUrl(baseUrl, nextLine);
const proxyUrl = `${proxyBase}/m3u8?url=${encodeURIComponent(resolvedUrl)}`;
const proxyUrl = `${proxyBase}/m3u8?url=${encodeURIComponent(resolvedUrl)}&moontv-source=${source}`;
rewrittenLines.push(proxyUrl);
} else {
rewrittenLines.push(nextLine);
@@ -158,23 +162,23 @@ function rewriteM3U8Content(content: string, baseUrl: string, req: Request, allo
return rewrittenLines.join('\n');
}
function rewriteMapUri(line: string, baseUrl: string, proxyBase: string) {
function rewriteMapUri(line: string, baseUrl: string, proxyBase: string, allowCORS: boolean, source: string) {
const uriMatch = line.match(/URI="([^"]+)"/);
if (uriMatch) {
const originalUri = uriMatch[1];
const resolvedUrl = resolveUrl(baseUrl, originalUri);
const proxyUrl = `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}`;
const proxyUrl = allowCORS ? resolvedUrl : `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}&moontv-source=${source}`;
return line.replace(uriMatch[0], `URI="${proxyUrl}"`);
}
return line;
}
function rewriteKeyUri(line: string, baseUrl: string, proxyBase: string) {
function rewriteKeyUri(line: string, baseUrl: string, proxyBase: string, allowCORS: boolean, source: string) {
const uriMatch = line.match(/URI="([^"]+)"/);
if (uriMatch) {
const originalUri = uriMatch[1];
const resolvedUrl = resolveUrl(baseUrl, originalUri);
const proxyUrl = `${proxyBase}/key?url=${encodeURIComponent(resolvedUrl)}`;
const proxyUrl = allowCORS ? resolvedUrl : `${proxyBase}/key?url=${encodeURIComponent(resolvedUrl)}&moontv-source=${source}`;
return line.replace(uriMatch[0], `URI="${proxyUrl}"`);
}
return line;

View File

@@ -3,6 +3,8 @@
import { NextResponse } from "next/server";
import { getConfig } from "@/lib/config";
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
import { buildProxyStreamHeaders } from '@/lib/server/proxy-headers';
export const runtime = 'nodejs';
@@ -33,6 +35,13 @@ export async function GET(request: Request) {
try {
const decodedUrl = decodeURIComponent(url);
// 安全校验:防 SSRF 拦截请求内网或非法 URL
const isSafeUrl = await validateProxyUrlServerSide(decodedUrl);
if (!isSafeUrl) {
return NextResponse.json({ error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 });
}
const response = await fetch(decodedUrl, {
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',
@@ -44,12 +53,9 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Failed to fetch key' }, { status: 500 });
}
const headers = new Headers();
headers.set('Content-Type', response.headers.get('Content-Type') || 'application/octet-stream');
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
const headers = buildProxyStreamHeaders(
response.headers.get('Content-Type') || 'application/octet-stream'
);
return new Response(response.body, { headers });
} catch (error) {

View File

@@ -4,6 +4,8 @@ import { NextResponse } from "next/server";
import { getConfig } from "@/lib/config";
import { getBaseUrl, resolveUrl } from "@/lib/live";
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
import { buildProxyM3u8Headers, buildProxyStreamHeaders } from '@/lib/server/proxy-headers';
export const runtime = 'nodejs';
@@ -38,6 +40,12 @@ export async function GET(request: Request) {
try {
const decodedUrl = decodeURIComponent(url);
// 安全校验:防 SSRF 拦截请求内网或非法 URL
const isSafeUrl = await validateProxyUrlServerSide(decodedUrl);
if (!isSafeUrl) {
return NextResponse.json({ error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 });
}
response = await fetch(decodedUrl, {
cache: 'no-cache',
redirect: 'follow',
@@ -66,23 +74,14 @@ export async function GET(request: Request) {
// 重写 M3U8 内容
const modifiedContent = rewriteM3U8Content(m3u8Content, baseUrl, request, source);
const headers = new Headers();
headers.set('Content-Type', contentType || 'application/vnd.apple.mpegurl');
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
headers.set('Cache-Control', 'no-cache');
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
const headers = buildProxyM3u8Headers(contentType || undefined);
return new Response(modifiedContent, { headers });
}
// just proxy
const headers = new Headers();
headers.set('Content-Type', response.headers.get('Content-Type') || 'application/vnd.apple.mpegurl');
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
const headers = buildProxyStreamHeaders(
response.headers.get('Content-Type') || 'application/vnd.apple.mpegurl'
);
headers.set('Cache-Control', 'no-cache');
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
// 直接返回视频流
return new Response(response.body, {

View File

@@ -1,8 +1,10 @@
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
import { NextResponse } from "next/server";
import { NextResponse } from 'next/server';
import { getConfig } from "@/lib/config";
import { getConfig } from '@/lib/config';
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
import { buildProxyStreamHeaders } from '@/lib/server/proxy-headers';
export const runtime = 'nodejs';
@@ -19,16 +21,22 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Missing source' }, { status: 400 });
}
// 检查该视频源是否启用了代理模式
const config = await getConfig();
const videoSource = config.SourceConfig?.find((s: any) => s.key === source);
// 定义直链播放模式常量
const DIRECT_PLAY_SOURCE = 'directplay';
if (!videoSource) {
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
}
// 直链播放模式:跳过源站配置检查,直接代理
if (source !== DIRECT_PLAY_SOURCE) {
// 检查该视频源是否启用了代理模式
const config = await getConfig();
const videoSource = config.SourceConfig?.find((s: any) => s.key === source);
if (!videoSource.proxyMode) {
return NextResponse.json({ error: 'Proxy mode not enabled for this source' }, { status: 403 });
if (!videoSource) {
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
}
if (!videoSource.proxyMode) {
return NextResponse.json({ error: 'Proxy mode not enabled for this source' }, { status: 403 });
}
}
let response: Response | null = null;
@@ -36,6 +44,13 @@ export async function GET(request: Request) {
try {
const decodedUrl = decodeURIComponent(url);
// 安全校验:防 SSRF 拦截请求内网或非法 URL (强制检查所有代理请求)
const isSafeUrl = await validateProxyUrlServerSide(decodedUrl);
if (!isSafeUrl) {
return NextResponse.json({ error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 });
}
response = await fetch(decodedUrl, {
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',
@@ -46,19 +61,14 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Failed to fetch segment' }, { status: 500 });
}
const headers = new Headers();
headers.set('Content-Type', response.headers.get('Content-Type') || 'video/mp2t');
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
headers.set('Accept-Ranges', 'bytes');
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
const contentLength = response.headers.get('content-length');
if (contentLength) {
headers.set('Content-Length', contentLength);
}
const headers = buildProxyStreamHeaders(
response.headers.get('Content-Type') || 'video/mp2t',
response.headers.get('content-length')
);
// 使用流式传输,避免占用内存
let isCancelled = false;
const stream = new ReadableStream({
start(controller) {
if (!response?.body) {
@@ -67,7 +77,6 @@ export async function GET(request: Request) {
}
reader = response.body.getReader();
const isCancelled = false;
function pump() {
if (isCancelled || !reader) {
@@ -109,6 +118,7 @@ export async function GET(request: Request) {
pump();
},
cancel() {
isCancelled = true;
// 当流被取消时,确保释放所有资源
if (reader) {
try {

View File

@@ -4,13 +4,13 @@ import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config';
import { searchFromApi } from '@/lib/downstream';
import { getDetailFromApiV2 } from '@/lib/downstream';
import { getProxyToken } from '@/lib/emby-token';
export const runtime = 'nodejs';
/**
* 根据 source 和 id 从搜索结果中精确匹配获取视频详情
* 根据 source 和 id 直接获取视频详情
* 这个API专门用于play页面快速获取当前源的详情
*/
export async function GET(request: NextRequest) {
@@ -22,10 +22,9 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
const sourceCode = searchParams.get('source');
const title = searchParams.get('title'); // 用于搜索的标题
const fileName = searchParams.get('fileName'); // 小雅源:用户点击的文件名
if (!id || !sourceCode || !title) {
if (!id || !sourceCode) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
}
@@ -385,7 +384,11 @@ export async function GET(request: NextRequest) {
}
}
// 对于其他源通过搜索API获取然后精确匹配
if (!/^[\w-]+$/.test(id)) {
return NextResponse.json({ error: '无效的视频ID格式' }, { status: 400 });
}
// 对于其他采集源,直接按 id 获取详情。
try {
const apiSites = await getAvailableApiSites(authInfo.username);
const apiSite = apiSites.find((site) => site.key === sourceCode);
@@ -394,26 +397,11 @@ export async function GET(request: NextRequest) {
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 }
);
}
const result = await getDetailFromApiV2(apiSite, id);
// 添加 proxyMode 到返回结果
const resultWithProxy = {
...exactMatch,
...result,
proxyMode: apiSite.proxyMode || false,
};

View File

@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server';
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
export const runtime = 'nodejs';
@@ -11,6 +12,12 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Missing video URL' }, { status: 400 });
}
// 安全校验:防 SSRF只允许合法的公网 URL
const isSafeUrl = await validateProxyUrlServerSide(videoUrl);
if (!isSafeUrl) {
return NextResponse.json({ error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 });
}
try {
// 获取客户端的Range请求头
const range = request.headers.get('range');

View File

@@ -84,6 +84,7 @@ export default async function RootLayout({
let aiEnableHomepageEntry = false;
let aiEnableVideoCardEntry = false;
let aiEnablePlayPageEntry = false;
let aiEnableComments = false;
let aiDefaultMessageNoVideo = '';
let aiDefaultMessageWithVideo = '';
let enableMovieRequest = true;
@@ -133,6 +134,7 @@ export default async function RootLayout({
aiEnableHomepageEntry = config.AIConfig?.EnableHomepageEntry || false;
aiEnableVideoCardEntry = config.AIConfig?.EnableVideoCardEntry || false;
aiEnablePlayPageEntry = config.AIConfig?.EnablePlayPageEntry || false;
aiEnableComments = config.AIConfig?.EnableAIComments || false;
aiDefaultMessageNoVideo = config.AIConfig?.DefaultMessageNoVideo || '';
aiDefaultMessageWithVideo = config.AIConfig?.DefaultMessageWithVideo || '';
// 求片功能配置
@@ -198,6 +200,9 @@ export default async function RootLayout({
AI_ENABLE_HOMEPAGE_ENTRY: aiEnableHomepageEntry,
AI_ENABLE_VIDEOCARD_ENTRY: aiEnableVideoCardEntry,
AI_ENABLE_PLAYPAGE_ENTRY: aiEnablePlayPageEntry,
AIConfig: {
EnableAIComments: aiEnableComments,
},
AI_DEFAULT_MESSAGE_NO_VIDEO: aiDefaultMessageNoVideo,
AI_DEFAULT_MESSAGE_WITH_VIDEO: aiDefaultMessageWithVideo,
ENABLE_MOVIE_REQUEST: enableMovieRequest,

View File

@@ -53,6 +53,7 @@ interface LiveSource {
from: 'config' | 'custom';
channelNumber?: number;
disabled?: boolean;
proxyMode?: 'full' | 'm3u8-only' | 'direct'; // 代理模式
}
function LivePageClient() {
@@ -295,6 +296,12 @@ function LivePageClient() {
// 工具函数Utils
// -----------------------------------------------------------------------------
// 获取 logo URL始终使用代理
const getLogoUrl = (logoUrl: string, sourceKey: string) => {
if (!logoUrl) return '';
return `/api/proxy/logo?url=${encodeURIComponent(logoUrl)}&source=${sourceKey}`;
};
// 获取直播源列表
const fetchLiveSources = async () => {
try {
@@ -435,7 +442,7 @@ function LivePageClient() {
title: selectedChannel.name,
source_name: source.name,
year: '',
cover: `/api/proxy/logo?url=${encodeURIComponent(selectedChannel.logo)}&source=${source.key}`,
cover: getLogoUrl(selectedChannel.logo, source.key),
index: 1,
total_episodes: 1,
play_time: 0,
@@ -626,7 +633,7 @@ function LivePageClient() {
title: channel.name,
source_name: currentSource.name,
year: '',
cover: `/api/proxy/logo?url=${encodeURIComponent(channel.logo)}&source=${currentSource.key}`,
cover: getLogoUrl(channel.logo, currentSource.key),
index: 1,
total_episodes: 1,
play_time: 0,
@@ -1203,7 +1210,7 @@ function LivePageClient() {
title: currentChannelRef.current.name,
source_name: currentSourceRef.current.name,
year: '',
cover: `/api/proxy/logo?url=${encodeURIComponent(currentChannelRef.current.logo)}&source=${currentSourceRef.current.key}`,
cover: getLogoUrl(currentChannelRef.current.logo, currentSourceRef.current.key),
total_episodes: 1,
save_time: Date.now(),
search_title: '',
@@ -1331,32 +1338,54 @@ function LivePageClient() {
super(config);
const load = this.load.bind(this);
this.load = function (context: any, config: any, callbacks: any) {
// 所有的请求都带一个 source 参数
try {
const url = new URL(context.url);
url.searchParams.set('moontv-source', currentSourceRef.current?.key || '');
context.url = url.toString();
} catch (error) {
// ignore
}
// 判断当前直播源的代理模式
const currentLiveSource = currentSourceRef.current;
const proxyMode = currentLiveSource?.proxyMode || 'full';
// 拦截manifest和level请求
if (
(context as any).type === 'manifest' ||
(context as any).type === 'level'
) {
// 判断是否浏览器直连
const isLiveDirectConnectStr = localStorage.getItem('liveDirectConnect');
const isLiveDirectConnect = isLiveDirectConnectStr === 'true';
if (isLiveDirectConnect) {
// 浏览器直连,使用 URL 对象处理参数
try {
const url = new URL(context.url);
url.searchParams.set('allowCORS', 'true');
context.url = url.toString();
} catch (error) {
// 如果 URL 解析失败,回退到字符串拼接
context.url = context.url + '&allowCORS=true';
// manifest 请求处理
if ((context as any).type === 'manifest') {
if (proxyMode === 'full') {
// 全量代理:添加 source 参数
try {
const url = new URL(context.url);
url.searchParams.set('moontv-source', currentSourceRef.current?.key || '');
context.url = url.toString();
} catch (error) {
// ignore
}
} else if (proxyMode === 'm3u8-only') {
// 仅代理m3u8模式添加 source 参数和 allowCORS 参数
try {
const url = new URL(context.url);
url.searchParams.set('moontv-source', currentSourceRef.current?.key || '');
url.searchParams.set('allowCORS', 'true');
context.url = url.toString();
} catch (error) {
context.url = context.url + '&allowCORS=true';
}
}
// direct 模式:直接使用原始 URL不添加任何参数
}
// level 请求ts 分片)处理
if ((context as any).type === 'level') {
if (proxyMode === 'full') {
// 全量代理:添加 source 参数
try {
const url = new URL(context.url);
url.searchParams.set('moontv-source', currentSourceRef.current?.key || '');
context.url = url.toString();
} catch (error) {
// ignore
}
}
// m3u8-only 模式ts 分片 URL 已经被代理服务器重写为原始 URL不需要添加参数
// direct 模式ts 分片直接使用原始 URL不添加任何参数
}
}
// 执行原始load方法
@@ -1463,26 +1492,34 @@ function LivePageClient() {
// precheck type
let type = 'm3u8';
try {
const precheckUrl = `/api/live/precheck?url=${encodeURIComponent(videoUrl)}&moontv-source=${currentSourceRef.current?.key || ''}`;
const precheckResponse = await fetch(precheckUrl);
if (!precheckResponse.ok) {
console.error('预检查失败:', precheckResponse.statusText);
const proxyMode = currentSourceRef.current?.proxyMode || 'full';
// 直连模式:跳过服务器预检查,直接使用 m3u8
if (proxyMode === 'direct') {
type = 'm3u8';
} else {
// 全量代理或仅代理m3u8通过服务器预检查
try {
const precheckUrl = `/api/live/precheck?url=${encodeURIComponent(videoUrl)}&moontv-source=${currentSourceRef.current?.key || ''}`;
const precheckResponse = await fetch(precheckUrl);
if (!precheckResponse.ok) {
console.error('预检查失败:', precheckResponse.statusText);
setIsVideoLoading(false);
return;
}
const precheckResult = await precheckResponse.json();
if (precheckResult?.success && precheckResult?.type) {
type = precheckResult.type;
} else {
console.error('预检查返回无效结果:', precheckResult);
setIsVideoLoading(false);
return;
}
} catch (err) {
console.error('预检查异常:', err);
setIsVideoLoading(false);
return;
}
const precheckResult = await precheckResponse.json();
if (precheckResult?.success && precheckResult?.type) {
type = precheckResult.type;
} else {
console.error('预检查返回无效结果:', precheckResult);
setIsVideoLoading(false);
return;
}
} catch (err) {
console.error('预检查异常:', err);
setIsVideoLoading(false);
return;
}
// 如果不是 m3u8、flv 或 mp4 类型,设置不支持的类型并返回
@@ -1496,9 +1533,19 @@ function LivePageClient() {
setUnsupportedType(null);
const customType = { m3u8: m3u8Loader, flv: flvLoader };
const targetUrl = (type === 'flv' || type === 'mp4')
? videoUrl
: `/api/proxy/m3u8?url=${encodeURIComponent(videoUrl)}&moontv-source=${currentSourceRef.current?.key || ''}`;
// 根据代理模式决定 URL
let targetUrl = videoUrl;
if (type === 'm3u8') {
if (proxyMode === 'direct') {
// 直连模式:直接使用原始 URL
targetUrl = videoUrl;
} else {
// 全量代理或仅代理m3u8使用代理 URL
targetUrl = `/api/proxy/m3u8?url=${encodeURIComponent(videoUrl)}&moontv-source=${currentSourceRef.current?.key || ''}`;
}
}
try {
// 创建新的播放器实例
Artplayer.USE_RAF = true;
@@ -2338,7 +2385,7 @@ function LivePageClient() {
<div className='w-10 h-10 bg-gray-300 dark:bg-gray-700 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden'>
{channel.logo ? (
<img
src={`/api/proxy/logo?url=${encodeURIComponent(channel.logo)}&source=${currentSource?.key || ''}`}
src={getLogoUrl(channel.logo, currentSource?.key || '')}
alt={channel.name}
className='w-full h-full rounded object-contain'
loading="lazy"
@@ -2462,7 +2509,7 @@ function LivePageClient() {
<div className='w-20 h-20 bg-gray-300 dark:bg-gray-700 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden'>
{currentChannel.logo ? (
<img
src={`/api/proxy/logo?url=${encodeURIComponent(currentChannel.logo)}&source=${currentSource?.key || ''}`}
src={getLogoUrl(currentChannel.logo, currentSource?.key || '')}
alt={currentChannel.name}
className='w-full h-full rounded object-contain'
loading="lazy"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,278 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
interface AIComment {
id: string;
userName: string;
userAvatar: string;
rating: number | null;
content: string;
time: string;
votes: number;
isAiGenerated: true;
}
interface AICommentsProps {
movieName: string;
movieInfo?: string;
}
export default function AIComments({ movieName, movieInfo }: AICommentsProps) {
const [comments, setComments] = useState<AIComment[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasStartedLoading, setHasStartedLoading] = useState(false);
const fetchComments = useCallback(async () => {
try {
console.log('正在生成AI评论...');
setLoading(true);
setError(null);
const params = new URLSearchParams({
name: movieName,
count: '10',
_t: Date.now().toString(), // 添加时间戳防止缓存
});
if (movieInfo) {
params.append('info', movieInfo);
}
const response = await fetch(`/api/ai-comments?${params.toString()}`, {
cache: 'no-store', // 禁用缓存
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || '生成AI评论失败');
}
const data = await response.json();
console.log('AI评论生成成功:', data.comments.length);
setComments(data.comments);
} catch (err) {
console.error('生成AI评论失败:', err);
setError(err instanceof Error ? err.message : '生成AI评论失败');
} finally {
setLoading(false);
}
}, [movieName, movieInfo]);
useEffect(() => {
// 重置状态当 movieName 变化时
setHasStartedLoading(false);
setComments([]);
setLoading(false);
setError(null);
}, [movieName]);
const startLoading = () => {
console.log('开始生成AI评论');
setHasStartedLoading(true);
fetchComments();
};
const regenerate = () => {
console.log('重新生成AI评论');
fetchComments();
};
// 星级渲染
const renderStars = (rating: number | null) => {
if (rating === null) return null;
return (
<div className='flex items-center gap-0.5'>
{[1, 2, 3, 4, 5].map((star) => (
<svg
key={star}
className='w-4 h-4'
fill={star <= rating ? '#3b82f6' : '#e0e0e0'}
viewBox='0 0 24 24'
>
<path d='M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z' />
</svg>
))}
</div>
);
};
// 初始状态:显示生成按钮
if (!hasStartedLoading) {
return (
<div className='flex flex-col items-center justify-center py-12'>
<div className='text-gray-500 dark:text-gray-400 mb-4'>
<svg
className='w-16 h-16 mx-auto mb-4 opacity-50'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={1.5}
d='M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z'
/>
</svg>
<p className='text-center'>AI评论</p>
<p className='text-xs text-center mt-2 text-gray-400'>
</p>
</div>
<button
onClick={startLoading}
className='px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors flex items-center gap-2'
>
<svg className='w-4 h-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M13 10V3L4 14h7v7l9-11h-7z'
/>
</svg>
AI评论
</button>
</div>
);
}
if (loading && comments.length === 0) {
return (
<div className='flex flex-col items-center justify-center py-12'>
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mb-3'></div>
<span className='text-gray-600 dark:text-gray-400'>AI正在生成评论...</span>
<span className='text-xs text-gray-500 dark:text-gray-500 mt-2'>
</span>
</div>
);
}
if (error && comments.length === 0) {
return (
<div className='text-center py-12'>
<div className='text-red-500 mb-2'></div>
<p className='text-gray-600 dark:text-gray-400 mb-1'>{error}</p>
<p className='text-xs text-gray-500 dark:text-gray-500 mb-4'>
AI配置是否正确
</p>
<button
onClick={startLoading}
className='px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors'
>
</button>
</div>
);
}
return (
<div className='space-y-4'>
{/* 头部统计和操作 */}
<div className='flex items-center justify-between'>
<div className='text-sm text-gray-600 dark:text-gray-400'>
{comments.length} AI评论
</div>
<button
onClick={regenerate}
disabled={loading}
className='text-sm px-3 py-1 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded-lg hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1'
>
<svg
className='w-4 h-4'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15'
/>
</svg>
{loading ? '生成中...' : '重新生成'}
</button>
</div>
{/* 评论列表 */}
<div className='space-y-4'>
{comments.map((comment) => (
<div
key={comment.id}
className='bg-blue-50/50 dark:bg-blue-900/10 rounded-lg p-4 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors border border-blue-100 dark:border-blue-900/30'
>
{/* 用户信息 */}
<div className='flex items-start gap-3 mb-3'>
{/* 头像 */}
<div className='flex-shrink-0'>
<img
src={comment.userAvatar}
alt={comment.userName}
className='w-10 h-10 rounded-full'
/>
</div>
{/* 用户名和评分 */}
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-2 flex-wrap'>
<span className='font-medium text-gray-900 dark:text-white'>
{comment.userName}
</span>
{renderStars(comment.rating)}
{/* AI标识 */}
<span className='inline-flex items-center gap-1 px-2 py-0.5 bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 text-xs rounded-full'>
<svg className='w-3 h-3' fill='currentColor' viewBox='0 0 24 24'>
<path d='M13 10V3L4 14h7v7l9-11h-7z' />
</svg>
AI生成
</span>
</div>
{/* 时间 */}
<div className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
{comment.time}
</div>
</div>
{/* 有用数 */}
{comment.votes > 0 && (
<div className='flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400'>
<svg
className='w-4 h-4'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5'
/>
</svg>
<span>{comment.votes}</span>
</div>
)}
</div>
{/* 评论内容 */}
<div className='text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap'>
{comment.content}
</div>
</div>
))}
</div>
{/* 提示信息 */}
<div className='text-center text-xs text-gray-500 dark:text-gray-400 py-2 border-t border-gray-200 dark:border-gray-700'>
AI基于影片信息和网络资料生成
</div>
</div>
);
}

View File

@@ -1,17 +1,19 @@
/* eslint-disable no-console */
'use client';
import { AlertTriangle } from 'lucide-react';
import { AlertTriangle, ChevronRight } from 'lucide-react';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import type { PlayRecord } from '@/lib/db.client';
import {
clearAllPlayRecords,
getCachedPlayRecordsSnapshot,
getAllPlayRecords,
subscribeToDataUpdates,
} from '@/lib/db.client';
import PlayRecordsPanel from '@/components/PlayRecordsPanel';
import VideoCard from '@/components/VideoCard';
import VirtualScrollableRow from '@/components/VirtualScrollableRow';
@@ -19,47 +21,58 @@ interface ContinueWatchingProps {
className?: string;
}
type PlayRecordItem = PlayRecord & { key: string };
export default function ContinueWatching({ className }: ContinueWatchingProps) {
const [playRecords, setPlayRecords] = useState<
(PlayRecord & { key: string })[]
>([]);
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
const cachedDisplayLimit = storageType !== 'localstorage' ? 10 : undefined;
const [playRecords, setPlayRecords] = useState<PlayRecordItem[]>([]);
const [loading, setLoading] = useState(true);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [showPlayRecordsPanel, setShowPlayRecordsPanel] = useState(false);
// 处理播放记录数据更新的函数
const updatePlayRecords = (allRecords: Record<string, PlayRecord>, limit?: number) => {
// 将记录转换为数组并根据 save_time 由近到远排序
const updatePlayRecords = (
allRecords: Record<string, PlayRecord>,
limit?: number
) => {
const recordsArray = Object.entries(allRecords).map(([key, record]) => ({
...record,
key,
}));
// 按 save_time 降序排序(最新的在前面)
const sortedRecords = recordsArray.sort(
(a, b) => b.save_time - a.save_time
);
const sortedRecords = recordsArray.sort((a, b) => b.save_time - a.save_time);
setPlayRecords(limit ? sortedRecords.slice(0, limit) : sortedRecords);
};
// 如果指定了 limit只取前 N 条
const finalRecords = limit ? sortedRecords.slice(0, limit) : sortedRecords;
const applyCachedSnapshot = () => {
const cachedRecords = getCachedPlayRecordsSnapshot();
if (Object.keys(cachedRecords).length === 0) {
return false;
}
setPlayRecords(finalRecords);
updatePlayRecords(cachedRecords, cachedDisplayLimit);
setLoading(false);
return true;
};
useEffect(() => {
const unsubscribe = subscribeToDataUpdates(
'playRecordsUpdated',
(newRecords: Record<string, PlayRecord>) => {
updatePlayRecords(newRecords);
setLoading(false);
}
);
const fetchPlayRecords = async () => {
try {
setLoading(true);
// 从缓存或API获取所有播放记录
const allRecords = await getAllPlayRecords();
// 非 localStorage 模式下,先只显示前 10 条记录
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (storageType !== 'localstorage') {
updatePlayRecords(allRecords, 10);
} else {
updatePlayRecords(allRecords);
const hasCachedSnapshot = applyCachedSnapshot();
if (!hasCachedSnapshot) {
setLoading(true);
}
const allRecords = await getAllPlayRecords();
updatePlayRecords(allRecords);
} catch (error) {
console.error('获取播放记录失败:', error);
setPlayRecords([]);
@@ -69,37 +82,23 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
};
fetchPlayRecords();
// 监听播放记录更新事件
const unsubscribe = subscribeToDataUpdates(
'playRecordsUpdated',
(newRecords: Record<string, PlayRecord>) => {
// 同步完成后,加载完整数据(不限制数量)
updatePlayRecords(newRecords);
}
);
return unsubscribe;
}, []);
}, [cachedDisplayLimit]);
// 如果没有播放记录,则不渲染组件
if (!loading && playRecords.length === 0) {
return null;
}
// 计算播放进度百分比
const getProgress = (record: PlayRecord) => {
if (record.total_time === 0) return 0;
return (record.play_time / record.total_time) * 100;
};
// 从 key 中解析 source 和 id
const parseKey = (key: string) => {
const [source, id] = key.split('+');
return { source, id };
};
// 处理清空确认
const handleClearConfirm = async () => {
await clearAllPlayRecords();
setPlayRecords([]);
@@ -114,173 +113,187 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
</h2>
{!loading && playRecords.length > 0 && (
<button
className='text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
onClick={() => setShowConfirmDialog(true)}
>
</button>
<div className='flex items-center gap-1'>
<button
className='text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
onClick={() => setShowConfirmDialog(true)}
>
</button>
<button
className='inline-flex h-8 w-8 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200'
onClick={() => setShowPlayRecordsPanel(true)}
aria-label='查看全部播放记录'
>
<ChevronRight className='h-4 w-4' />
</button>
</div>
)}
</div>
{loading ? (
// 加载状态显示灰色占位数据(使用原始 ScrollableRow
<div className="flex gap-2 overflow-x-auto scrollbar-hide pt-2 pb-2">
{Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className='min-w-[180px] w-48 sm:min-w-[200px] sm:w-52'
>
<div className='relative aspect-[3/2] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
{loading ? (
<div className='flex gap-2 overflow-x-auto scrollbar-hide pb-2 pt-2'>
{Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className='min-w-[180px] w-48 sm:min-w-[200px] sm:w-52'
>
<div className='relative aspect-[3/2] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700' />
</div>
<div className='mt-1 h-1 rounded bg-gray-200 animate-pulse dark:bg-gray-800' />
<div className='mt-2 h-4 w-3/4 rounded bg-gray-200 animate-pulse dark:bg-gray-800' />
</div>
<div className='mt-1 h-1 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800 w-3/4'></div>
</div>
))}
</div>
) : (
// 使用虚拟滚动显示真实数据
<div>
<VirtualScrollableRow>
{playRecords.map((record) => {
const { source, id } = parseKey(record.key);
return (
<div
key={record.key}
className='min-w-[180px] w-48 sm:min-w-[200px] sm:w-52'
style={{ position: 'relative' }}
>
<VideoCard
id={id}
title={record.title}
poster={record.cover}
year={record.year}
source={source}
source_name={record.source_name}
progress={getProgress(record)}
episodes={record.total_episodes}
currentEpisode={record.index}
query={record.search_title}
from='playrecord'
onDelete={() =>
setPlayRecords((prev) =>
prev.filter((r) => r.key !== record.key)
)
}
type={record.total_episodes > 1 ? 'tv' : ''}
origin={record.origin}
orientation='horizontal'
playTime={record.play_time}
totalTime={record.total_time}
/>
{/* 新增剧集提示 - 完全独立于 VideoCard */}
{record.new_episodes && record.new_episodes > 0 && (
<div
style={{
position: 'absolute',
top: '-6px',
right: '-6px',
zIndex: 100,
pointerEvents: 'none',
width: '28px',
height: '28px',
}}
>
{/* 水波纹动画 - 第一层 */}
))}
</div>
) : (
<div>
<VirtualScrollableRow>
{playRecords.map((record) => {
const { source, id } = parseKey(record.key);
return (
<div
key={record.key}
className='min-w-[180px] w-48 sm:min-w-[200px] sm:w-52'
style={{ position: 'relative' }}
>
<VideoCard
id={id}
title={record.title}
poster={record.cover}
year={record.year}
source={source}
source_name={record.source_name}
progress={getProgress(record)}
episodes={record.total_episodes}
currentEpisode={record.index}
query={record.search_title}
from='playrecord'
onDelete={() =>
setPlayRecords((prev) =>
prev.filter((item) => item.key !== record.key)
)
}
type={record.total_episodes > 1 ? 'tv' : ''}
origin={record.origin}
orientation='horizontal'
playTime={record.play_time}
totalTime={record.total_time}
/>
{record.new_episodes && record.new_episodes > 0 && (
<div
style={{
position: 'absolute',
inset: '0',
borderRadius: '9999px',
backgroundColor: 'rgb(14 165 233)',
animation: 'ping-scale 1.5s cubic-bezier(0, 0, 0.2, 1) infinite',
}}
/>
{/* 水波纹动画 - 第二层 */}
<div
style={{
position: 'absolute',
inset: '0',
borderRadius: '9999px',
backgroundColor: 'rgb(14 165 233)',
animation: 'pulse-scale 2.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
}}
/>
{/* 主体徽章 */}
<div
style={{
position: 'absolute',
inset: '0',
borderRadius: '9999px',
background: 'linear-gradient(to bottom right, rgb(14 165 233), rgb(2 132 199))',
color: 'white',
fontSize: '11px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
animation: 'badge-scale 2s ease-in-out infinite',
top: '-6px',
right: '-6px',
zIndex: 100,
pointerEvents: 'none',
width: '28px',
height: '28px',
}}
>
+{record.new_episodes}
<div
style={{
position: 'absolute',
inset: '0',
borderRadius: '9999px',
backgroundColor: 'rgb(14 165 233)',
animation:
'ping-scale 1.5s cubic-bezier(0, 0, 0.2, 1) infinite',
}}
/>
<div
style={{
position: 'absolute',
inset: '0',
borderRadius: '9999px',
backgroundColor: 'rgb(14 165 233)',
animation:
'pulse-scale 2.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
}}
/>
<div
style={{
position: 'absolute',
inset: '0',
borderRadius: '9999px',
background:
'linear-gradient(to bottom right, rgb(14 165 233), rgb(2 132 199))',
color: 'white',
fontSize: '11px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow:
'0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
animation: 'badge-scale 2s ease-in-out infinite',
}}
>
+{record.new_episodes}
</div>
</div>
</div>
)}
</div>
);
})}
</VirtualScrollableRow>
</div>
)}
</section>
{/* 确认对话框 */}
{showConfirmDialog && createPortal(
<div
className='fixed inset-0 bg-black bg-opacity-50 z-[9999] flex items-center justify-center p-4 transition-opacity duration-300'
onClick={() => setShowConfirmDialog(false)}
>
<div
className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full border border-red-200 dark:border-red-800 transition-all duration-300'
onClick={(e) => e.stopPropagation()}
>
<div className="p-6">
{/* 图标和标题 */}
<div className="flex items-start gap-4 mb-4">
<div className="flex-shrink-0">
<AlertTriangle className="w-8 h-8 text-red-500" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
</p>
</div>
</div>
{/* 按钮组 */}
<div className="flex gap-3 mt-6">
<button
onClick={() => setShowConfirmDialog(false)}
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
>
</button>
<button
onClick={handleClearConfirm}
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors"
>
</button>
</div>
)}
</div>
);
})}
</VirtualScrollableRow>
</div>
</div>
</div>,
document.body
)}
</>
)}
</section>
{showConfirmDialog &&
createPortal(
<div
className='fixed inset-0 z-[9999] flex items-center justify-center bg-black bg-opacity-50 p-4 transition-opacity duration-300'
onClick={() => setShowConfirmDialog(false)}
>
<div
className='max-w-md w-full rounded-lg border border-red-200 bg-white shadow-xl transition-all duration-300 dark:border-red-800 dark:bg-gray-800'
onClick={(event) => event.stopPropagation()}
>
<div className='p-6'>
<div className='mb-4 flex items-start gap-4'>
<div className='flex-shrink-0'>
<AlertTriangle className='h-8 w-8 text-red-500' />
</div>
<div className='flex-1'>
<h3 className='mb-2 text-lg font-semibold text-gray-900 dark:text-gray-100'>
</h3>
<p className='text-sm text-gray-600 dark:text-gray-400'>
</p>
</div>
</div>
<div className='mt-6 flex gap-3'>
<button
onClick={() => setShowConfirmDialog(false)}
className='flex-1 rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
>
</button>
<button
onClick={handleClearConfirm}
className='flex-1 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700'
>
</button>
</div>
</div>
</div>
</div>,
document.body
)}
{showPlayRecordsPanel &&
createPortal(
<PlayRecordsPanel
isOpen={showPlayRecordsPanel}
onClose={() => setShowPlayRecordsPanel(false)}
/>,
document.body
)}
</>
);
}

View File

@@ -166,7 +166,7 @@ export default function DanmakuPanel({
<div className='flex h-full flex-col overflow-hidden'>
{/* 搜索区域 - 固定在顶部 */}
<div className='mb-4 flex-shrink-0'>
<div className='flex gap-2'>
<div className='flex flex-wrap gap-2'>
<input
type='text'
value={searchKeyword}
@@ -183,7 +183,7 @@ export default function DanmakuPanel({
spellCheck='false'
data-form-type='other'
data-lpignore='true'
className='flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm
className='flex-1 min-w-[220px] rounded-lg border border-gray-300 px-3 py-2 text-sm
transition-colors focus:border-green-500 focus:outline-none
focus:ring-2 focus:ring-green-500/20
dark:border-gray-600 dark:bg-gray-800 dark:text-white
@@ -193,18 +193,18 @@ export default function DanmakuPanel({
<button
onClick={() => handleSearch(searchKeyword)}
disabled={isSearching}
className='flex items-center justify-center gap-2 rounded-lg bg-green-500 px-3 py-2
className='flex flex-shrink-0 items-center justify-center gap-2 rounded-lg bg-green-500 px-3 py-2
text-sm font-medium text-white transition-colors
hover:bg-green-600 disabled:cursor-not-allowed
disabled:opacity-50 dark:bg-green-600 dark:hover:bg-green-700
sm:px-4 md:gap-2'
lg:px-4 min-w-[44px]'
>
{isSearching ? (
<div className='h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent' />
) : (
<MagnifyingGlassIcon className='h-4 w-4' />
)}
<span className='hidden sm:inline'>
<span className='hidden lg:inline'>
{isSearching ? '搜索中...' : '搜索'}
</span>
</button>
@@ -382,9 +382,18 @@ export default function DanmakuPanel({
{/* 信息 */}
<div className='min-w-0 flex-1'>
<p className='truncate font-semibold text-gray-800 dark:text-white'>
{anime.animeTitle}
</p>
<div className='relative'>
<p className='truncate font-semibold text-gray-800 dark:text-white peer'>
{anime.animeTitle}
</p>
{/* 自定义 tooltip */}
<div
className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-800 text-white text-xs rounded-md shadow-lg opacity-0 invisible peer-hover:opacity-100 peer-hover:visible transition-all duration-200 ease-out delay-100 whitespace-nowrap pointer-events-none z-[100]'
>
{anime.animeTitle}
<div className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800' />
</div>
</div>
<div className='mt-1 flex flex-wrap items-center gap-2 text-xs text-gray-600 dark:text-gray-400'>
<span className='rounded bg-gray-200 px-2 py-0.5 dark:bg-gray-700'>
{anime.typeDescription || anime.type}

View File

@@ -1,8 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
'use client';
import { AlertCircle, Copy, ExternalLink, Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { AlertCircle, Copy, ExternalLink, Loader2, RefreshCw } from 'lucide-react';
import { useEffect, useState, useCallback } from 'react';
import { PansouLink, PansouSearchResult } from '@/lib/pansou.client';
@@ -57,51 +57,52 @@ export default function PansouSearch({
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
const [selectedType, setSelectedType] = useState<string>('all'); // 'all' 表示显示全部
// 提取搜索函数,以便在重试时调用
const searchPansou = useCallback(async () => {
const currentKeyword = keyword.trim();
if (!currentKeyword) {
return;
}
setLoading(true);
setError(null);
setResults(null);
try {
const response = await fetch('/api/pansou/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
keyword: currentKeyword,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || '搜索失败');
}
const data: PansouSearchResult = await response.json();
setResults(data);
} catch (err: any) {
const errorMsg = err.message || '搜索失败,请检查配置';
setError(errorMsg);
onError?.(errorMsg);
} finally {
setLoading(false);
}
}, [keyword, onError]);
useEffect(() => {
// triggerSearch 变化时触发搜索(无论是 true 还是 false
if (triggerSearch === undefined) {
return;
}
const currentKeyword = keyword.trim();
if (!currentKeyword) {
return;
}
const searchPansou = async () => {
setLoading(true);
setError(null);
setResults(null);
try {
const response = await fetch('/api/pansou/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
keyword: currentKeyword,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || '搜索失败');
}
const data: PansouSearchResult = await response.json();
setResults(data);
} catch (err: any) {
const errorMsg = err.message || '搜索失败,请检查配置';
setError(errorMsg);
onError?.(errorMsg);
} finally {
setLoading(false);
}
};
searchPansou();
}, [triggerSearch, onError]); // 移除 keyword 依赖,只依赖 triggerSearch
}, [triggerSearch, searchPansou]); // 依赖 triggerSearch 和 searchPansou
const handleCopy = async (text: string, url: string) => {
try {
@@ -136,6 +137,13 @@ export default function PansouSearch({
<div className='text-center'>
<AlertCircle className='mx-auto h-12 w-12 text-red-500 dark:text-red-400' />
<p className='mt-4 text-sm text-red-600 dark:text-red-400'>{error}</p>
<button
onClick={searchPansou}
className='mt-4 inline-flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-lg transition-colors'
>
<RefreshCw className='h-4 w-4' />
</button>
</div>
</div>
);

View File

@@ -0,0 +1,229 @@
'use client';
import { AlertTriangle, History, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import type { PlayRecord } from '@/lib/db.client';
import {
clearAllPlayRecords,
getAllPlayRecords,
subscribeToDataUpdates,
} from '@/lib/db.client';
import VideoCard from '@/components/VideoCard';
type PlayRecordItem = PlayRecord & {
key: string;
};
interface PlayRecordsPanelProps {
isOpen: boolean;
onClose: () => void;
}
const parseKey = (key: string) => {
const [source, id] = key.split('+');
return { source, id };
};
const getProgress = (record: PlayRecord) => {
if (record.total_time === 0) return 0;
return (record.play_time / record.total_time) * 100;
};
export default function PlayRecordsPanel({
isOpen,
onClose,
}: PlayRecordsPanelProps) {
const [playRecords, setPlayRecords] = useState<PlayRecordItem[]>([]);
const [loading, setLoading] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const loadPlayRecords = async () => {
setLoading(true);
try {
const allRecords = await getAllPlayRecords();
const sorted = Object.entries(allRecords)
.map(([key, record]) => ({
...record,
key,
}))
.sort((a, b) => b.save_time - a.save_time);
setPlayRecords(sorted);
} catch (error) {
console.error('加载播放记录失败:', error);
setPlayRecords([]);
} finally {
setLoading(false);
}
};
const handleClearAll = async () => {
try {
await clearAllPlayRecords();
setPlayRecords([]);
setShowConfirmDialog(false);
} catch (error) {
console.error('清空播放记录失败:', error);
}
};
useEffect(() => {
if (!isOpen) return;
loadPlayRecords();
}, [isOpen]);
useEffect(() => {
const unsubscribe = subscribeToDataUpdates(
'playRecordsUpdated',
(newRecords: Record<string, PlayRecord>) => {
if (!isOpen) return;
const sorted = Object.entries(newRecords)
.map(([key, record]) => ({
...record,
key,
}))
.sort((a, b) => b.save_time - a.save_time);
setPlayRecords(sorted);
}
);
return () => {
unsubscribe();
};
}, [isOpen]);
return (
<>
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
onClick={onClose}
/>
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-4xl max-h-[85vh] bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] flex flex-col overflow-hidden'>
<div className='flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700'>
<div className='flex items-center gap-2'>
<History className='w-5 h-5 text-sky-500' />
<h3 className='text-lg font-bold text-gray-800 dark:text-gray-200'>
</h3>
{playRecords.length > 0 && (
<span className='px-2 py-0.5 text-xs font-medium bg-sky-100 text-sky-800 dark:bg-sky-900/30 dark:text-sky-300 rounded-full'>
{playRecords.length}
</span>
)}
</div>
<div className='flex items-center gap-2'>
{playRecords.length > 0 && (
<button
onClick={() => setShowConfirmDialog(true)}
className='text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors'
>
</button>
)}
<button
onClick={onClose}
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
aria-label='Close'
>
<X className='w-full h-full' />
</button>
</div>
</div>
<div className='flex-1 overflow-y-auto p-6'>
{loading ? (
<div className='flex items-center justify-center py-12'>
<div className='w-8 h-8 border-4 border-sky-500 border-t-transparent rounded-full animate-spin'></div>
</div>
) : playRecords.length === 0 ? (
<div className='flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400'>
<History className='w-12 h-12 mb-3 opacity-30' />
<p className='text-sm'></p>
</div>
) : (
<div className='grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8'>
{playRecords.map((record) => {
const { source, id } = parseKey(record.key);
return (
<div key={record.key} className='w-full'>
<VideoCard
id={id}
title={record.title}
poster={record.cover}
year={record.year}
source={source}
source_name={record.source_name}
progress={getProgress(record)}
episodes={record.total_episodes}
currentEpisode={record.index}
query={record.search_title}
from='playrecord'
onDelete={() =>
setPlayRecords((prev) =>
prev.filter((item) => item.key !== record.key)
)
}
type={record.total_episodes > 1 ? 'tv' : ''}
origin={record.origin}
playTime={record.play_time}
totalTime={record.total_time}
/>
</div>
);
})}
</div>
)}
</div>
</div>
{showConfirmDialog &&
createPortal(
<div
className='fixed inset-0 bg-black bg-opacity-50 z-[9999] flex items-center justify-center p-4 transition-opacity duration-300'
onClick={() => setShowConfirmDialog(false)}
>
<div
className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full border border-red-200 dark:border-red-800 transition-all duration-300'
onClick={(e) => e.stopPropagation()}
>
<div className='p-6'>
<div className='flex items-start gap-4 mb-4'>
<div className='flex-shrink-0'>
<AlertTriangle className='w-8 h-8 text-red-500' />
</div>
<div className='flex-1'>
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2'>
</h3>
<p className='text-sm text-gray-600 dark:text-gray-400'>
</p>
</div>
</div>
<div className='flex gap-3 mt-6'>
<button
onClick={() => setShowConfirmDialog(false)}
className='flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors'
>
</button>
<button
onClick={handleClearAll}
className='flex-1 px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors'
>
</button>
</div>
</div>
</div>
</div>,
document.body
)}
</>
);
}

View File

@@ -114,7 +114,6 @@ export const UserMenu: React.FC = () => {
const [enableOptimization, setEnableOptimization] = useState(true);
const [speedTestTimeout, setSpeedTestTimeout] = useState(4000); // 测速超时时间(毫秒)
const [fluidSearch, setFluidSearch] = useState(true);
const [liveDirectConnect, setLiveDirectConnect] = useState(false);
const [tmdbBackdropDisabled, setTmdbBackdropDisabled] = useState(false);
const [enableTrailers, setEnableTrailers] = useState(false);
const [doubanDataSource, setDoubanDataSource] = useState('cmliussss-cdn-tencent');
@@ -486,11 +485,6 @@ export const UserMenu: React.FC = () => {
setFluidSearch(defaultFluidSearch);
}
const savedLiveDirectConnect = localStorage.getItem('liveDirectConnect');
if (savedLiveDirectConnect !== null) {
setLiveDirectConnect(JSON.parse(savedLiveDirectConnect));
}
const savedTmdbBackdropDisabled = localStorage.getItem('tmdb_backdrop_disabled');
if (savedTmdbBackdropDisabled !== null) {
setTmdbBackdropDisabled(savedTmdbBackdropDisabled === 'true');
@@ -1040,13 +1034,6 @@ export const UserMenu: React.FC = () => {
}
};
const handleLiveDirectConnectToggle = (value: boolean) => {
setLiveDirectConnect(value);
if (typeof window !== 'undefined') {
localStorage.setItem('liveDirectConnect', JSON.stringify(value));
}
};
const handleTmdbBackdropDisabledToggle = (value: boolean) => {
setTmdbBackdropDisabled(value);
if (typeof window !== 'undefined') {
@@ -1255,7 +1242,6 @@ export const UserMenu: React.FC = () => {
setDefaultAggregateSearch(true);
setEnableOptimization(true);
setFluidSearch(defaultFluidSearch);
setLiveDirectConnect(false);
setTmdbBackdropDisabled(false);
setEnableTrailers(false);
setDoubanProxyUrl(defaultDoubanProxy);
@@ -2031,30 +2017,6 @@ export const UserMenu: React.FC = () => {
</label>
</div>
{/* 直播视频浏览器直连 */}
<div className='flex items-center justify-between'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
IPTV
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
IPTV Allow CORS
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={liveDirectConnect}
onChange={(e) => handleLiveDirectConnectToggle(e.target.checked)}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
</div>
{/* 禁用背景图渲染 */}
<div className='flex items-center justify-between'>
<div>

View File

@@ -1077,7 +1077,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
return (
<div
className='absolute bottom-2 right-2 opacity-0 transition-all duration-300 ease-in-out delay-75 sm:group-hover:opacity-100'
className={`absolute bottom-1 right-1 sm:bottom-2 sm:right-2 transition-all duration-300 ease-in-out delay-75 ${
from === 'search' ? 'opacity-100' : 'opacity-0 sm:group-hover:opacity-100'
}`}
style={{
WebkitUserSelect: 'none',
userSelect: 'none',

View File

@@ -0,0 +1,21 @@
import { useEffect, useState } from 'react';
interface RuntimeConfig {
AIConfig?: {
EnableAIComments?: boolean;
};
}
export function useEnableAIComments(): boolean {
const [enableAIComments, setEnableAIComments] = useState(false);
useEffect(() => {
// 在客户端获取运行时配置
if (typeof window !== 'undefined') {
const runtimeConfig = (window as any).RUNTIME_CONFIG as RuntimeConfig;
setEnableAIComments(runtimeConfig?.AIConfig?.EnableAIComments ?? false);
}
}, []);
return enableAIComments;
}

View File

@@ -96,6 +96,7 @@ export interface AdminConfig {
from: 'config' | 'custom';
channelNumber?: number;
disabled?: boolean;
proxyMode?: 'full' | 'm3u8-only' | 'direct'; // 代理模式full=全量代理m3u8-only=仅代理m3u8direct=直连
}[];
WebLiveConfig?: {
key: string;
@@ -169,6 +170,7 @@ export interface AdminConfig {
EnableHomepageEntry: boolean; // 首页入口开关
EnableVideoCardEntry: boolean; // VideoCard入口开关
EnablePlayPageEntry: boolean; // 播放页入口开关
EnableAIComments: boolean; // AI评论生成开关
// 权限控制
AllowRegularUsers: boolean; // 是否允许普通用户使用AI问片关闭后仅站长和管理员可用
// 高级设置

View File

@@ -0,0 +1,284 @@
// AI评论生成核心逻辑
export interface AIComment {
id: string;
userName: string;
userAvatar: string;
rating: number | null;
content: string;
time: string;
votes: number;
isAiGenerated: true;
}
interface GenerateCommentsParams {
movieName: string;
movieInfo?: string;
count?: number;
aiConfig: {
CustomApiKey: string;
CustomBaseURL: string;
CustomModel: string;
Temperature?: number;
MaxTokens?: number;
EnableWebSearch?: boolean;
WebSearchProvider?: 'tavily' | 'serper' | 'serpapi';
TavilyApiKey?: string;
SerperApiKey?: string;
SerpApiKey?: string;
};
}
interface CommentData {
content: string;
rating: number | null;
sentiment: 'positive' | 'neutral' | 'negative';
}
// 生成评论的Prompt
function buildCommentPrompt(
movieName: string,
movieInfo?: string,
searchResults?: string,
count: number = 10
): string {
return `你是一个影评生成助手。请生成真实自然的观众评论。
影片:${movieName}
${movieInfo ? `简介:${movieInfo}` : ''}
${searchResults ? `\n网络评价参考\n${searchResults}` : ''}
任务要求:
1. 生成${count}条观众评论
2. 每条评论50-200字口语化、自然
3. 观点多样化有好评、中评、差评比例大约6:3:1
4. 可以包含:
- 个人观影感受和情感共鸣
- 对演员演技的评价
- 对剧情、节奏、画面的看法
- 与其他作品的对比
- 推荐或不推荐的理由
5. 避免:
- 过于专业的影评术语
- 千篇一律的表达
- 明显的AI痕迹
- 重复的内容
请直接输出JSON数组格式不要有其他文字
[
{
"content": "评论内容",
"rating": 4,
"sentiment": "positive"
}
]
注意rating为1-5的整数或null表示未评分sentiment为positive/neutral/negative之一。`;
}
// 联网搜索影片资料
async function searchMovieInfo(
movieName: string,
aiConfig: GenerateCommentsParams['aiConfig']
): Promise<string> {
if (!aiConfig.EnableWebSearch) {
return '';
}
try {
const provider = aiConfig.WebSearchProvider || 'tavily';
let searchResults = '';
if (provider === 'tavily' && aiConfig.TavilyApiKey) {
const response = await fetch('https://api.tavily.com/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
api_key: aiConfig.TavilyApiKey,
query: `${movieName} 影评 评价`,
max_results: 5,
}),
});
if (response.ok) {
const data = await response.json();
searchResults = data.results
?.map((r: any) => r.content)
.join('\n')
.slice(0, 1000);
}
} else if (provider === 'serper' && aiConfig.SerperApiKey) {
const response = await fetch('https://google.serper.dev/search', {
method: 'POST',
headers: {
'X-API-KEY': aiConfig.SerperApiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
q: `${movieName} 影评 评价`,
num: 5,
}),
});
if (response.ok) {
const data = await response.json();
searchResults = data.organic
?.map((r: any) => r.snippet)
.join('\n')
.slice(0, 1000);
}
} else if (provider === 'serpapi' && aiConfig.SerpApiKey) {
const response = await fetch(
`https://serpapi.com/search?q=${encodeURIComponent(movieName + ' 影评 评价')}&api_key=${aiConfig.SerpApiKey}&num=5`
);
if (response.ok) {
const data = await response.json();
searchResults = data.organic_results
?.map((r: any) => r.snippet)
.join('\n')
.slice(0, 1000);
}
}
return searchResults;
} catch (error) {
console.error('搜索影片资料失败:', error);
return '';
}
}
// 调用AI生成评论
export async function generateAIComments(
params: GenerateCommentsParams
): Promise<AIComment[]> {
const { movieName, movieInfo, count = 10, aiConfig } = params;
try {
// 1. 联网搜索影片资料(如果启用)
const searchResults = await searchMovieInfo(movieName, aiConfig);
// 2. 构建Prompt
const prompt = buildCommentPrompt(movieName, movieInfo, searchResults, count);
// 3. 调用AI API
const response = await fetch(`${aiConfig.CustomBaseURL}/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${aiConfig.CustomApiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: aiConfig.CustomModel,
messages: [
{
role: 'system',
content:
'你是一个专业的影评生成助手,擅长生成真实自然的观众评论。',
},
{
role: 'user',
content: prompt,
},
],
temperature: aiConfig.Temperature ?? 0.8,
max_tokens: aiConfig.MaxTokens ?? 2000,
}),
});
if (!response.ok) {
throw new Error(`AI API调用失败: ${response.status}`);
}
const data = await response.json();
const content = data.choices?.[0]?.message?.content;
if (!content) {
throw new Error('AI返回内容为空');
}
// 4. 解析AI返回的JSON
let commentsData: CommentData[];
try {
// 尝试提取JSON可能被markdown代码块包裹
const jsonMatch = content.match(/\[[\s\S]*\]/);
if (jsonMatch) {
commentsData = JSON.parse(jsonMatch[0]);
} else {
commentsData = JSON.parse(content);
}
} catch (parseError) {
console.error('解析AI返回的JSON失败:', content);
throw new Error('AI返回格式错误');
}
// 5. 转换为AIComment格式
const aiComments: AIComment[] = commentsData.map((comment, index) => {
const timestamp = Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000; // 随机过去30天内
const date = new Date(timestamp);
return {
id: `ai-${Date.now()}-${index}`,
userName: generateUserName(index),
userAvatar: generateAvatar(index),
rating: comment.rating,
content: comment.content,
time: formatTime(date),
votes: generateVotes(comment.sentiment),
isAiGenerated: true,
};
});
return aiComments;
} catch (error) {
console.error('AI评论生成失败:', error);
throw error;
}
}
// 生成虚拟用户名
function generateUserName(index: number): string {
const prefixes = [
'影迷',
'观众',
'电影爱好者',
'剧迷',
'路人',
'网友',
'看客',
];
const prefix = prefixes[index % prefixes.length];
return `${prefix}${Math.floor(Math.random() * 9000) + 1000}`;
}
// 生成头像URL使用DiceBear API
function generateAvatar(seed: number): string {
const styles = ['avataaars', 'bottts', 'personas', 'micah'];
const style = styles[seed % styles.length];
return `https://api.dicebear.com/7.x/${style}/svg?seed=${seed}`;
}
// 格式化时间
function formatTime(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
// 根据情感生成点赞数
function generateVotes(sentiment: string): number {
if (sentiment === 'positive') {
return Math.floor(Math.random() * 100) + 20; // 20-120
} else if (sentiment === 'neutral') {
return Math.floor(Math.random() * 50) + 5; // 5-55
} else {
return Math.floor(Math.random() * 30); // 0-30
}
}

View File

@@ -10,6 +10,33 @@ export interface ChangelogEntry {
}
export const changelog: ChangelogEntry[] = [
{
version: "215.0.0",
date: "2026-03-20",
added: [
"增加主动恢复进度按钮",
"新增播放记录面板",
"弹幕搜索面板标题增加tooltip",
"影视搜索新增列表视图",
"pansou增加重试按钮",
"新增ai评论生成",
"增加一键render部署",
"电视直播增加三种代理模式"
],
changed: [
"优化直链播放m3u8体验",
"搜索页面海量数据下使用虚拟滚动提高性能",
"优化emby代理内存泄漏问题",
"电视直播代理控制权从用户端改为管理端",
"获取视频源详情不再依赖title"
],
fixed: [
"修复search页面僵尸历史记录tag",
"修复弹幕搜索框挤压",
"修复搜索页面加载条显示顺序错误",
"修复继续观看渐进式加载的一些问题"
]
},
{
version: "214.1.0",
date: "2026-03-10",

View File

@@ -716,6 +716,42 @@ export async function getAllPlayRecords(): Promise<Record<string, PlayRecord>> {
}
}
export function getCachedPlayRecordsSnapshot(): Record<string, PlayRecord> {
if (typeof window === 'undefined') {
return {};
}
if (STORAGE_TYPE !== 'localstorage') {
const cachedRecords = cacheManager.getCachedPlayRecords();
if (cachedRecords) {
return cachedRecords;
}
try {
const username = getAuthInfoFromBrowserCookie()?.username;
if (!username) return {};
const raw = localStorage.getItem(`${CACHE_PREFIX}${username}`);
if (!raw) return {};
const userCache = JSON.parse(raw) as UserCacheStore;
return userCache.playRecords?.data || {};
} catch (err) {
console.error('读取用户播放记录快照失败:', err);
return {};
}
}
try {
const raw = localStorage.getItem(PLAY_RECORDS_KEY);
if (!raw) return {};
return JSON.parse(raw) as Record<string, PlayRecord>;
} catch (err) {
console.error('读取本地播放记录快照失败:', err);
return {};
}
}
/**
* 保存播放记录。
* 数据库存储模式下使用乐观更新:先更新缓存(立即生效),再异步同步到数据库。

View File

@@ -293,6 +293,90 @@ export async function getDetailFromApi(
};
}
export async function getDetailFromApiV2(
apiSite: ApiSite,
id: string
): Promise<SearchResult> {
const detailUrl = `${apiSite.api}${API_CONFIG.detail.path}${id}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(detailUrl, {
headers: API_CONFIG.detail.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`详情请求失败: ${response.status}`);
}
const data = await response.json();
if (
!data ||
!data.list ||
!Array.isArray(data.list) ||
data.list.length === 0
) {
throw new Error('获取到的详情内容无效');
}
const videoDetail = data.list[0];
let episodes: string[] = [];
let titles: string[] = [];
if (videoDetail.vod_play_url) {
const vodPlayUrlArray = videoDetail.vod_play_url.split('$$$');
vodPlayUrlArray.forEach((url: string) => {
const matchEpisodes: string[] = [];
const matchTitles: string[] = [];
const titleUrlArray = url.split('#');
titleUrlArray.forEach((titleUrl: string) => {
const episodeTitleUrl = titleUrl.split('$');
if (
episodeTitleUrl.length === 2 &&
episodeTitleUrl[1].endsWith('.m3u8')
) {
matchTitles.push(episodeTitleUrl[0]);
matchEpisodes.push(episodeTitleUrl[1]);
}
});
if (matchEpisodes.length > episodes.length) {
episodes = matchEpisodes;
titles = matchTitles;
}
});
}
if (episodes.length === 0 && videoDetail.vod_content) {
const matches = videoDetail.vod_content.match(M3U8_PATTERN) || [];
episodes = matches.map((link: string) => link.replace(/^\$/, ''));
}
return {
id: id.toString(),
title: videoDetail.vod_name,
poster: videoDetail.vod_pic,
episodes,
episodes_titles: titles,
source: apiSite.key,
source_name: apiSite.name,
class: videoDetail.vod_class,
year: videoDetail.vod_year
? videoDetail.vod_year.match(/\d{4}/)?.[0] || ''
: 'unknown',
desc: cleanHtmlTags(videoDetail.vod_content),
type_name: videoDetail.type_name,
douban_id: videoDetail.vod_douban_id,
vod_remarks: videoDetail.vod_remarks,
vod_total: videoDetail.vod_total,
proxyMode: apiSite.proxyMode || false,
};
}
async function handleSpecialSourceDetail(
id: string,
apiSite: ApiSite

View File

@@ -1,7 +1,7 @@
import { getAvailableApiSites } from '@/lib/config';
import { SearchResult } from '@/lib/types';
import { getDetailFromApi, searchFromApi } from './downstream';
import { getDetailFromApiV2 } from './downstream';
import { getSpecialSourceDetail, isSpecialSource } from './special-sources-detail';
interface FetchVideoDetailOptions {
@@ -13,13 +13,12 @@ interface FetchVideoDetailOptions {
/**
* 根据 source 与 id 获取视频详情。
* 1. 如果是特殊源emby、openlist、xiaoya直接调用对应的获取函数。
* 2. 若传入 fallbackTitle则先调用 /api/search 搜索精确匹配
* 3. 若搜索未命中或未提供 fallbackTitle则直接调用 /api/detail。
* 2. 其他采集源直接调用详情接口,避免依赖搜索接口
*/
export async function fetchVideoDetail({
source,
id,
fallbackTitle = '',
fallbackTitle: _fallbackTitle = '',
}: FetchVideoDetailOptions): Promise<SearchResult> {
// 检查是否是特殊源emby、openlist、xiaoya
if (isSpecialSource(source)) {
@@ -30,30 +29,13 @@ export async function fetchVideoDetail({
// 如果特殊源返回 null继续使用标准流程
}
// 优先通过搜索接口查找精确匹配
const apiSites = await getAvailableApiSites();
const apiSite = apiSites.find((site) => site.key === source);
if (!apiSite) {
throw new Error('无效的API来源');
}
if (fallbackTitle) {
try {
const searchData = await searchFromApi(apiSite, fallbackTitle.trim());
const exactMatch = searchData.find(
(item: SearchResult) =>
item.source.toString() === source.toString() &&
item.id.toString() === id.toString()
);
if (exactMatch) {
return exactMatch;
}
} catch (error) {
// do nothing
}
}
// 调用 /api/detail 接口
const detail = await getDetailFromApi(apiSite, id);
const detail = await getDetailFromApiV2(apiSite, id);
if (!detail) {
throw new Error('获取视频详情失败');
}

View File

@@ -0,0 +1,39 @@
/**
* 代理接口共享工具函数
* 用于构建标准化的 CORS 响应头,避免各代理路由中的重复代码。
*/
/**
* 构建标准的代理流式响应头 (用于 ts 分片、密钥、二进制流等)
* 包含 CORS、Accept-Ranges 和 Content-Length 等标准头。
*/
export function buildProxyStreamHeaders(
contentType: string,
contentLength?: string | null
): Headers {
const headers = new Headers();
headers.set('Content-Type', contentType);
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
headers.set('Accept-Ranges', 'bytes');
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
if (contentLength) {
headers.set('Content-Length', contentLength);
}
return headers;
}
/**
* 构建标准的代理 M3U8 播放列表响应头
*/
export function buildProxyM3u8Headers(contentType?: string): Headers {
const headers = new Headers();
headers.set('Content-Type', contentType || 'application/vnd.apple.mpegurl');
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
headers.set('Cache-Control', 'no-cache');
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
return headers;
}

94
src/lib/server/ssrf.ts Normal file
View File

@@ -0,0 +1,94 @@
import dns from 'dns';
/**
* 判断 IP 地址是否为内网/本地私有地址
* 覆盖 IPv4 和 IPv6彻底杜绝所有变体绕过。
*/
export function isPrivateIP(ip: string): boolean {
// IPv4 私有地址和环回地址
if (ip.includes('.')) {
const parts = ip.split('.').map(Number);
if (parts.length !== 4) return false;
return (
parts[0] === 10 || // 10.x.x.x
parts[0] === 127 || // 127.x.x.x (Loopback)
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || // 172.16.x.x - 172.31.x.x
(parts[0] === 192 && parts[1] === 168) || // 192.168.x.x
(parts[0] === 169 && parts[1] === 254) || // 169.254.x.x (Link-local)
parts[0] === 0 // 0.x.x.x ("This network")
);
}
// IPv6 私有地址和环回地址
if (ip.includes(':')) {
// ::1 环回地址 (Loopback)
if (ip === '::1' || ip === '0:0:0:0:0:0:0:1') return true;
// 0::0 / :: 未指定地址
if (ip === '::' || ip === '0:0:0:0:0:0:0:0') return true;
const lowerIp = ip.toLowerCase();
// IPv4 映射到 IPv6 的地址 (例如 ::ffff:127.0.0.1)
if (lowerIp.startsWith('::ffff:')) {
return isPrivateIP(lowerIp.substring(7));
}
// 唯一本地地址 (Unique Local Addresses, fc00::/7)
if (lowerIp.startsWith('fc') || lowerIp.startsWith('fd')) return true;
// 链路本地地址 (Link-Local Addresses, fe80::/10)
if (lowerIp.startsWith('fe8') || lowerIp.startsWith('fe9') || lowerIp.startsWith('fea') || lowerIp.startsWith('feb')) return true;
}
return false;
}
/**
* 校验代理 URL 是否安全 (防止 SSRF / DNS 重绑定漏洞)
* 只在 Node.js 服务端运行。
*/
export async function validateProxyUrlServerSide(urlStr: string): Promise<boolean> {
if (!urlStr) return false;
try {
const parsed = new URL(urlStr);
// 1. 协议检查
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false;
}
// 2. 剥离认证信息防止混淆
if (parsed.username || parsed.password) {
return false;
}
let { hostname } = parsed;
// 清洗 IPv6 括号边界
if (hostname.startsWith('[') && hostname.endsWith(']')) {
hostname = hostname.substring(1, hostname.length - 1);
}
// 3. DNS 真实解析 (获取底层物理 IP)
// 这一步能彻底打碎各种形式的短格式 IP (127.1)、八/十六进制 IP (0x7f.0.0.1)、或者指向 127.0.0.1 的恶意外部域名 DNS 重绑定。
const lookupResult = await dns.promises.lookup(hostname);
if (!lookupResult || !lookupResult.address) {
return false; // 解析不出 IP 则拒绝
}
// 4. 对物理 IP 进行内网校验
if (isPrivateIP(lookupResult.address)) {
console.warn(`[SSRF 防护] 拦截到尝试访问内部网络的请求 URL: ${urlStr} (解析出的底层 IP: ${lookupResult.address})`);
return false;
}
return true;
} catch (error) {
// 凡是报错(无论是 URL 解析失败,还是 DNS 解析失败,还是域名不存在),均作为不安全拒绝
console.warn(`[SSRF 防护] URL解析失败或不合法, 拒绝代理请求: ${urlStr}`);
return false;
}
}

View File

@@ -155,7 +155,7 @@ export function processVideoUrl(originalUrl: string): string {
*/
export async function getVideoResolutionFromM3u8(
m3u8Url: string,
timeoutMs = 4000
timeoutMs = 6000
): Promise<{
quality: string; // 如720p、1080p等
loadSpeed: string; // 自动转换为KB/s或MB/s
@@ -185,11 +185,55 @@ export async function getVideoResolutionFromM3u8(
// 固定使用hls.js加载
const hls = new Hls();
// 设置超时处理 - 使用传入的超时时间
const timeout = setTimeout(() => {
let actualLoadSpeed = '未知';
let hasSpeedCalculated = false;
let hasMetadataLoaded = false;
let estimatedBitrate = 0; // 估算的码率bps
// 提取核心返回逻辑供 resolve 和 timeout 共同调用
const resolveCurrentState = () => {
const width = video.videoWidth;
const quality =
width >= 3840
? '4K'
: width >= 2560
? '2K'
: width >= 1920
? '1080p'
: width >= 1280
? '720p'
: width >= 854
? '480p'
: width > 0
? 'SD'
: '未知';
const bitrateStr = estimatedBitrate > 0
? estimatedBitrate >= 1000000
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
: `${Math.round(estimatedBitrate / 1000)} Kbps`
: '未知';
hls.destroy();
video.remove();
reject(new Error('Timeout loading video metadata'));
resolve({
quality,
loadSpeed: actualLoadSpeed,
pingTime: Math.round(pingTime),
bitrate: bitrateStr,
});
};
// 设置超时处理 - 如果部分数据已拿到,则宽容返回
const timeout = setTimeout(() => {
if (hasMetadataLoaded || hasSpeedCalculated) {
resolveCurrentState();
} else {
hls.destroy();
video.remove();
reject(new Error('Timeout loading video metadata'));
}
}, timeoutMs);
video.onerror = () => {
@@ -199,67 +243,16 @@ export async function getVideoResolutionFromM3u8(
reject(new Error('Failed to load video metadata'));
};
let actualLoadSpeed = '未知';
let hasSpeedCalculated = false;
let hasMetadataLoaded = false;
let estimatedBitrate = 0; // 估算的码率bps
let fragmentStartTime = 0;
// 检查是否可以返回结果
// 检查是否可以相互满足要求
const checkAndResolve = () => {
if (
hasMetadataLoaded &&
(hasSpeedCalculated || actualLoadSpeed !== '未知')
) {
clearTimeout(timeout);
const width = video.videoWidth;
if (width && width > 0) {
hls.destroy();
video.remove();
// 根据视频宽度判断视频质量等级,使用经典分辨率的宽度作为分割点
const quality =
width >= 3840
? '4K' // 4K: 3840x2160
: width >= 2560
? '2K' // 2K: 2560x1440
: width >= 1920
? '1080p' // 1080p: 1920x1080
: width >= 1280
? '720p' // 720p: 1280x720
: width >= 854
? '480p'
: 'SD'; // 480p: 854x480
// 格式化码率
const bitrateStr = estimatedBitrate > 0
? estimatedBitrate >= 1000000
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
: `${Math.round(estimatedBitrate / 1000)} Kbps`
: '未知';
resolve({
quality,
loadSpeed: actualLoadSpeed,
pingTime: Math.round(pingTime),
bitrate: bitrateStr,
});
} else {
// webkit 无法获取尺寸,直接返回
const bitrateStr = estimatedBitrate > 0
? estimatedBitrate >= 1000000
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
: `${Math.round(estimatedBitrate / 1000)} Kbps`
: '未知';
resolve({
quality: '未知',
loadSpeed: actualLoadSpeed,
pingTime: Math.round(pingTime),
bitrate: bitrateStr,
});
}
resolveCurrentState();
}
};
@@ -309,7 +302,7 @@ export async function getVideoResolutionFromM3u8(
});
// 为分片请求添加时间戳参数破除浏览器缓存
hls.config.xhrSetup = function(xhr: XMLHttpRequest, url: string) {
hls.config.xhrSetup = function (xhr: XMLHttpRequest, url: string) {
const urlWithTimestamp = url.includes('?')
? `${url}&_t=${Date.now()}`
: `${url}?_t=${Date.now()}`;
@@ -323,6 +316,22 @@ export async function getVideoResolutionFromM3u8(
hls.on(Hls.Events.ERROR, (event: any, data: any) => {
console.error('HLS错误:', data);
if (data.fatal) {
const statusCode = data.response?.code || data.response?.status;
// 防止 415 代理兜底熔断导致正常的二进制源在优选逻辑中被剔除
if (statusCode === 415 && (m3u8Url.includes('/api/proxy-m3u8') || m3u8Url.includes('/api/proxy/vod/m3u8'))) {
console.log('[测速] 测速通道嗅探到这是底层的媒体流文件,免测速通过');
clearTimeout(timeout);
hls.destroy();
video.remove();
resolve({
quality: '原生画质',
loadSpeed: '直连',
pingTime: 10,
bitrate: '未知',
});
return;
}
clearTimeout(timeout);
hls.destroy();
video.remove();
@@ -397,3 +406,4 @@ export function base58Decode(encoded: string): string {
// 在 Node.js 环境中使用 Buffer
return Buffer.from(bytes).toString('utf-8');
}

View File

@@ -1,6 +1,6 @@
/* eslint-disable no-console */
const CURRENT_VERSION = '214.1.0';
const CURRENT_VERSION = '215.0.0';
// 导出当前版本号供其他地方使用
export { CURRENT_VERSION };